Add workspaces, conversation sessions, app-managed computers and pictures

- Rail: Add icon with stacked form modals (bot, workspace, computer),
  coordinator, recent bots with "See all bots", Workspaces section, and
  a workspace mode listing only its crew
- Workspaces: page for context, bots (per-workspace instructions and
  memory), computers (default, link, unlink), models and delete;
  conversations scoped per bot per workspace
- Sessions: conversations split into searchable, reopenable sessions
  with carry-over summaries; sessions and new-session header icons
- Computers: created and provisioned over the docker socket, home dir
  kept at DATA_DIR/computers/<id> after delete; super admins only
- Models: workspace settings win over a bot's own, then the defaults
- Pictures for bots and workspaces; bots editable from the header
- Coordinator tools: update_workspace, set_bot_workspace_instructions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import pickFreeHue from "./pick-free-hue";
describe("pickFreeHue", () => {
test("takes the first hue nobody has", () => {
expect(pickFreeHue({ taken: [] })).toBe("ember");
expect(pickFreeHue({ taken: ["ember", null, "amber"] })).toBe("moss");
});
test("falls back to the least used hue", () => {
const all = ["ember", "amber", "moss", "teal", "cobalt", "violet", "rose"];
expect(pickFreeHue({ taken: [...all, ...all.filter((h) => h != "teal")] })).toBe("teal");
});
});
+22
View File
@@ -0,0 +1,22 @@
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
type Params = {
/**
* Hues the crew already has.
*/
taken: (string | null | undefined)[];
};
/**
* The first hue nobody on the crew has yet, else the least used one, so a
* new bot is told apart at a glance.
*/
export default function pickFreeHue({ taken }: Params): BotHue {
const counts = new Map<string, number>(BotHues.map((hue) => [hue.value, 0]));
for (const hue of taken) {
if (hue && counts.has(hue)) counts.set(hue, counts.get(hue)! + 1);
}
return [...BotHues].sort((a, b) => counts.get(a.value)! - counts.get(b.value)!)[0]!.value;
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import type { BotWithLastMessage } from "@/src/types";
import pickRailBots from "./pick-rail-bots";
const bot = (id: number, extra: Partial<BotWithLastMessage> = {}): BotWithLastMessage => ({
id,
name: `Bot ${id}`,
face: "idle",
...extra,
});
describe("pickRailBots", () => {
test("lists the most recent up to the limit and hides the rest", () => {
const { shown, hidden } = pickRailBots({ workers: [bot(1), bot(2), bot(3)], limit: 2 });
expect(shown.map((b) => b.id)).toEqual([1, 2]);
expect(hidden.map((b) => b.id)).toEqual([3]);
});
test("keeps an older bot that's open, unread or needs you", () => {
const { shown, hidden } = pickRailBots({
workers: [bot(1), bot(2), bot(3, { unread: 1 }), bot(4, { face: "needs-you" }), bot(5), bot(6)],
selected_id: 5,
limit: 1,
});
expect(shown.map((b) => b.id)).toEqual([1, 3, 4, 5]);
expect(hidden.map((b) => b.id)).toEqual([2, 6]);
});
});
@@ -0,0 +1,29 @@
import type { BotWithLastMessage } from "@/src/types";
type Params = {
/**
* The worker bots, latest activity first.
*/
workers: BotWithLastMessage[];
/**
* The open conversation's bot: always listed.
*/
selected_id?: number;
limit: number;
};
/**
* Which workers the rail lists: the `limit` most recently active, plus any
* older one that's open, unread or needs the user, so nothing that wants
* attention hides behind "See all bots". The rest go to `hidden`, in the
* same order.
*/
export default function pickRailBots({ workers, selected_id, limit }: Params) {
const shown = workers.filter(
(bot, index) =>
index < limit || bot.id == selected_id || Boolean(bot.unread) || bot.face == "needs-you",
);
const hidden = workers.filter((bot) => !shown.includes(bot));
return { shown, hidden };
}