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
@@ -33,6 +33,10 @@ export type BotComputers = {
type Params = {
bot_id: number;
/**
* In a workspace, switching changes the workspace's default computer.
*/
workspace_id?: number | null;
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
@@ -42,7 +46,7 @@ type Params = {
* arrives as `server:bot-updated` with the bot's `computer_id`. Also holds
* the computer open in the viewer (`ComputerViewerModal`).
*/
export default function useBotComputers({ bot_id, computers: initial }: Params): BotComputers {
export default function useBotComputers({ bot_id, workspace_id, computers: initial }: Params): BotComputers {
const [computers, setComputers] = useState(initial);
const [switching_id, setSwitchingId] = useState<number>();
const [error, setError] = useState<string>();
@@ -71,7 +75,7 @@ export default function useBotComputers({ bot_id, computers: initial }: Params):
setSwitchingId(computer_id);
setError(undefined);
const res = await setBotComputer({ bot_id, computer_id }).catch(() => null);
const res = await setBotComputer({ bot_id, computer_id, workspace_id }).catch(() => null);
if (res?.success && res.payload?.length) {
setComputers(res.payload);
@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import type { BotSessionListItem } from "@/src/types";
import postSessionApi from "../(functions)/post-session-api";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Loads only while the modal is open.
*/
open: boolean;
};
/**
* A conversation's sessions, latest first, searched as the user types
* (debounced). Starting or reopening a session reloads the page onto it.
*/
export default function useBotSessions({ bot_id, workspace_id, open }: Params) {
const [query, setQuery] = useState("");
const [sessions, setSessions] = useState<BotSessionListItem[]>([]);
const [loading, setLoading] = useState(false);
const [busy_id, setBusyId] = useState<number | "new">();
const [error, setError] = useState<string>();
useEffect(() => {
if (!open) return;
let stale = false;
const timer = setTimeout(async () => {
setLoading(true);
const res = await postSessionApi<BotSessionListItem>({
path: "list-sessions",
body: { bot_id, workspace_id, query: query.trim() || undefined },
}).catch(() => null);
if (stale) return;
setLoading(false);
if (res?.success) {
setSessions(res.payload || []);
setError(undefined);
} else {
setError(res?.msg || "Couldn't load the sessions.");
}
}, query ? 250 : 0);
return () => {
stale = true;
clearTimeout(timer);
};
}, [open, query, bot_id, workspace_id]);
const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => {
if (busy_id) return;
setBusyId(id);
setError(undefined);
const res = await postSessionApi({ path, body }).catch(() => null);
if (res?.success) {
window.location.reload();
return;
}
setBusyId(undefined);
setError(res?.msg || "That didn't work.");
};
return {
query,
setQuery,
sessions,
loading,
busy_id,
error,
startSession: () => act({ id: "new", path: "new-session", body: { bot_id, workspace_id } }),
reopenSession: ({ session_id }: { session_id: number }) =>
act({ id: session_id, path: "reopen-session", body: { session_id } }),
};
}
@@ -4,6 +4,10 @@ import type { BotJobUpdateData, BotTaskWithSteps, BotUpdatedData } from "@/src/t
type Params = {
bot_id: number;
/**
* The open session: steps of jobs from other sessions are left out.
*/
session_id?: number | null;
/**
* The bot's active task when the page loaded.
*/
@@ -27,13 +31,14 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
* row's text), kept current from `server:bot-job-update`. Starts over when
* `server:bot-updated` brings a different task, or none.
*/
export default function useTaskProgress({ bot_id, task }: Params) {
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
useServerEvent({
event: "server:bot-job-update",
onData: (update: BotJobUpdateData) => {
if (update.bot_id != bot_id || !update.task_id || !update.text) return;
if (session_id && update.session_id && update.session_id != session_id) return;
setProgress({ task_id: update.task_id, text: update.text });
},