Make a workspace page a dashboard and drop its coordinator from the rail

The workspace rail now lists only the bots attached to the workspace, with the back link returning to the workspace page from a bot's conversation. /admin/workspaces/<id> becomes a read-only dashboard (picture, context, latest activity, bots, computers) that links into the editable settings page.
This commit is contained in:
2026-10-19 13:40:59 +01:00
parent 7b1ccfeb62
commit e5747effa8
16 changed files with 425 additions and 63 deletions
+4 -4
View File
@@ -89,7 +89,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown)), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown)), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
- **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's page (`(sections)/workspace-overview.tsx`: context, bots with their per-workspace instructions and memory, computers, delete, models) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. - **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models. - **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). User messages stay plain text. - **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). User messages stay plain text.
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While a reply runs (face `working`, status `BotReplyJobTitle`) or waits for a model (`BotWaitingStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads "<name> is working…" with the task's latest step. A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. - **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While a reply runs (face `working`, status `BotReplyJobTitle`) or waits for a model (`BotWaitingStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads "<name> is working…" with the task's latest step. A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
@@ -100,7 +100,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`). - **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`).
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`. - **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`). - **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A deleted bot is pushed as `server:bot-removed` (`BotRemovedData`) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
- **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace page, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, and changes any workspace's name, description or context with `update_workspace` (chat replies). - **Workspaces:** `workspaces` (`name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`, `grab-workspace-activity`), all access-checked against the session user, the structural ones for super admins and admins only; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, and changes any workspace's name, description or context with `update_workspace` (chat replies).
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`. - **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (the install's, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are every computer), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
- **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`). - **Opening a computer:** the app serves each computer to whoever sees it (`computersSql`) under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
- **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting. - **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting.
@@ -137,7 +137,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
## Admin Route Hierarchy ## Admin Route Hierarchy
Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspaces` (the index opens the first workspace), `/admin/routines`, `/admin/logs`, `/admin/help` and `/admin/settings`. `/admin` redirects to `/admin/bots`; there is no dashboard. The rest below are built but closed for now (redirect to `/admin/bots`): Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspaces` (the index opens the first workspace), `/admin/routines`, `/admin/logs`, `/admin/help` and `/admin/settings`. `/admin` redirects to `/admin/bots`; a workspace's dashboard is `/admin/workspaces/<id>`. The rest below are built but closed for now (redirect to `/admin/bots`):
- `/admin/people` - admin users list, plus `create/`, `[user_id]/` (profile), `[user_id]/edit/` - `/admin/people` - admin users list, plus `create/`, `[user_id]/` (profile), `[user_id]/edit/`
- `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/` - `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/`
@@ -148,7 +148,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` - `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), those three for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry - `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), those three for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
## Page Directory Rules ## Page Directory Rules
+4
View File
@@ -220,6 +220,10 @@ export const AppData = {
* Stones shown beside "See all bots". * Stones shown beside "See all bots".
*/ */
RailSeeAllStones: 4, RailSeeAllStones: 4,
/**
* Recent sessions a workspace's dashboard shows.
*/
WorkspaceActivityItems: 6,
/** /**
* Tool turns a chat reply may take. Long work goes to a task instead. * Tool turns a chat reply may take. Long work goes to a task instead.
*/ */
@@ -0,0 +1,40 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import type { WorkspaceActivityItem } from "@/src/types";
type Params = {
user_id: number | string;
workspace_id: number | string;
};
/**
* The user's latest conversations in a workspace, most recent first, each
* with the bot it's with, its message count and latest message. Empty
* sessions are left out unless active.
*/
export default async function grabWorkspaceActivity({
user_id,
workspace_id,
}: Params): Promise<WorkspaceActivityItem[]> {
const res = await BunSQLite.sql<WorkspaceActivityItem>({
sql: `SELECT s.*,
b.name AS bot_name,
b.image_media_id AS bot_image_media_id,
b.hue AS bot_hue,
b.face AS bot_face,
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text
FROM bot_sessions s
JOIN bots b ON b.id = s.bot_id
WHERE s.workspace_id = ? AND s.user_id = ?
AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))
AND (b.kind = 'coordinator' OR EXISTS (
SELECT 1 FROM workspace_bots wb WHERE wb.workspace_id = s.workspace_id AND wb.bot_id = s.bot_id
))
ORDER BY COALESCE(s.last_activity_at, s.created_at) DESC, s.id DESC
LIMIT ?`,
values: [workspace_id, user_id, AppData.WorkspaceActivityItems],
});
return res.payload || [];
}
@@ -2,7 +2,6 @@ import Divider from "@moduletrace/twui/layout/Divider";
import { useContext } from "react"; import { useContext } from "react";
import { AddFlowContext } from "../(data)/add-flow-context"; import { AddFlowContext } from "../(data)/add-flow-context";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types"; import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import CoordinatorRow from "./coordinator-row";
import RailBackLink from "./rail-back-link"; import RailBackLink from "./rail-back-link";
import RailBotsSection from "./rail-bots-section"; import RailBotsSection from "./rail-bots-section";
import RailSectionAddButton from "./rail-section-add-button"; import RailSectionAddButton from "./rail-section-add-button";
@@ -11,7 +10,8 @@ import WorkspaceRow from "./workspace-row";
type Props = { type Props = {
workspace: WorkspaceWithActivity; workspace: WorkspaceWithActivity;
/** /**
* The workspace's crew: the coordinator and its bots. * The workspace's crew: the coordinator is left out, only the bots
* attached to the workspace show.
*/ */
bots: BotWithLastMessage[]; bots: BotWithLastMessage[];
selected_id?: number; selected_id?: number;
@@ -23,28 +23,22 @@ type Props = {
/** /**
* The rail inside a workspace: back to all bots, the workspace itself (its * The rail inside a workspace: back to all bots, the workspace itself (its
* page), its coordinator conversation, then the workspace's bots. Every * page), then the workspace's bots. Every conversation here runs with the
* conversation here runs with the workspace's context. * workspace's context. The coordinator is reached from the main rail.
*/ */
export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) { export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) {
const { openForm } = useContext(AddFlowContext); const { openForm } = useContext(AddFlowContext);
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator"); const workers = bots.filter((bot) => bot.kind !== "coordinator");
const base = `/admin/workspaces/${workspace.id}`; const base = `/admin/workspaces/${workspace.id}`;
return ( return (
<> <>
<RailBackLink title="All bots" /> <RailBackLink
title={on_workspace_page ? "All bots" : `Back to ${workspace.name}`}
href={on_workspace_page ? undefined : base}
/>
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} /> <WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
{coordinator ? (
<CoordinatorRow
bot={coordinator}
bots={bots}
href={`${base}/bots/${coordinator.id}`}
selected={!on_workspace_page && coordinator.id === selected_id}
/>
) : null}
<RailBotsSection <RailBotsSection
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />} action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />}
title="Workspace bots" title="Workspace bots"
@@ -1,7 +1,5 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent"; import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import { Settings } from "lucide-react";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import { AppContext } from "@/src/pages/__root"; import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading"; import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
@@ -13,19 +11,12 @@ import WorkspaceDetailsGroup from "../(partials)/workspace-details-group";
import WorkspaceImageGroup from "../(partials)/workspace-image-group"; import WorkspaceImageGroup from "../(partials)/workspace-image-group";
import WorkspaceModels from "../(partials)/workspace-models"; import WorkspaceModels from "../(partials)/workspace-models";
type Props = {
/**
* On the workspace's own page: show the link into its settings.
*/
show_settings_link?: boolean;
};
/** /**
* A workspace's page: its picture, context, bots, computers and models, * A workspace's settings: its picture, context, bots, computers and models,
* then deleting it. Super admins and admins manage it; a member with no * then deleting it. Super admins and admins manage it; a member with no
* bots sees an empty note. * bots sees an empty note.
*/ */
export default function WorkspaceOverview({ show_settings_link }: Props) { export default function WorkspaceOverview() {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const [workspace, setWorkspace] = useState(pageProps?.workspace); const [workspace, setWorkspace] = useState(pageProps?.workspace);
@@ -56,16 +47,7 @@ export default function WorkspaceOverview({ show_settings_link }: Props) {
workspace.description || workspace.description ||
"Context, bots, computers and models for this workspace." "Context, bots, computers and models for this workspace."
} }
breadcrumb_title={show_settings_link ? undefined : name} breadcrumb_title={name}
action={
show_settings_link ? (
<HeaderIconButton
icon={Settings}
title="Workspace settings"
href={`/admin/settings/workspaces/${workspace_id}`}
/>
) : undefined
}
> >
<WorkspaceImageGroup workspace={workspace} /> <WorkspaceImageGroup workspace={workspace} />
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} /> <WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
@@ -0,0 +1,59 @@
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
type Props = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
};
/**
* What the workspace is: its picture, name, what it's for, and the context
* every bot working here gets.
*/
export default function WorkspaceAboutGroup({ workspace }: Props) {
const name = workspace.name || "Workspace";
const context = workspace.system_prompt?.trim();
return (
<SettingsGroup
id="about"
title="Workspace"
description="Its picture, what it's for and the context every bot here gets."
>
<Row className="w-full gap-4! flex-nowrap items-center">
<WorkspaceAvatar
size="lg"
image_media_id={workspace.image_media_id}
/>
<Stack className="grow min-w-0 gap-0!">
<Span className="text-base font-bold leading-6 text-ink truncate w-full">
{name}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{workspace.description || "No description yet."}
</Span>
</Stack>
</Row>
{context ? (
<Stack className="gap-3! items-stretch min-w-0">
<Markdown
remarkPlugins={[remarkGfm]}
components={MessageMarkdownComponents}
>
{context}
</Markdown>
</Stack>
) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
No context yet. Every bot here works from its own instructions alone.
</Span>
)}
</SettingsGroup>
);
}
@@ -0,0 +1,39 @@
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import type { WorkspaceActivityItem } from "@/src/types";
import WorkspaceActivityRow from "./workspace-activity-row";
type Props = {
workspace_id: number;
/**
* The user's latest sessions in the workspace, most recent first.
*/
activity: WorkspaceActivityItem[];
};
/**
* What has happened in the workspace lately: the user's latest
* conversations, each opening the bot's conversation here.
*/
export default function WorkspaceActivityGroup({ workspace_id, activity }: Props) {
return (
<SettingsGroup
id="activity"
title="Latest activity"
description="Your latest conversations with the bots here."
>
<SettingsList
is_empty={!activity.length}
empty="No conversations here yet. Open a bot below to start one."
>
{activity.map((item) => (
<WorkspaceActivityRow
key={item.id}
workspace_id={workspace_id}
item={item}
/>
))}
</SettingsList>
</SettingsGroup>
);
}
@@ -0,0 +1,43 @@
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import type { WorkspaceActivityItem } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
import relativeTime from "@/src/utils/relative-time";
type Props = {
workspace_id: number;
item: WorkspaceActivityItem;
};
/**
* One recent conversation in the workspace: the bot, the session's title,
* a preview of its latest message and when it last moved.
*/
export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
const at = item.last_activity_at || item.created_at;
const preview = item.last_message_text
? messagePreview({ text: item.last_message_text })
: "";
return (
<SettingsListRow
avatar={
<BotAvatar
image_media_id={item.bot_image_media_id}
hue={(item.bot_hue || null) as BotHue | null}
face={(item.bot_face || "idle") as BotFace}
/>
}
title={item.title || "Untitled conversation"}
href={`/admin/workspaces/${workspace_id}/bots/${item.bot_id}`}
badge={item.unread ? "New" : undefined}
subtitle={`${item.bot_name || "A bot"}${preview ? ` · ${preview}` : ""}`}
>
<Span className="text-[11px] leading-4 text-ink-secondary">
{at ? relativeTime({ at: Number(at) }) : ""}
</Span>
</SettingsListRow>
);
}
@@ -0,0 +1,39 @@
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import type { WorkspaceBot } from "@/src/types";
import WorkspaceDashboardBotRow from "./workspace-dashboard-bot-row";
type Props = {
workspace_id: number;
/**
* The workspace's crew, the coordinator among them.
*/
bots: WorkspaceBot[];
};
/**
* Who works here, each opening its conversation in the workspace. Display
* only: bots are added and taken out under Settings.
*/
export default function WorkspaceBotsListGroup({ workspace_id, bots }: Props) {
return (
<SettingsGroup
id="bots"
title="Bots"
description="Everyone working here. The coordinator is in every workspace."
>
<SettingsList
is_empty={!bots.length}
empty="No bots here yet. Add some under Settings."
>
{bots.map((bot) => (
<WorkspaceDashboardBotRow
key={bot.id}
workspace_id={workspace_id}
bot={bot}
/>
))}
</SettingsList>
</SettingsGroup>
);
}
@@ -0,0 +1,38 @@
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import type { ComputerWithStatus } from "@/src/types";
import WorkspaceDashboardComputerRow from "./workspace-dashboard-computer-row";
type Props = {
/**
* The computers linked to the workspace, its default first.
*/
computers: ComputerWithStatus[];
};
/**
* The computers bots work on here, with their state. Display only:
* computers are linked and set up under Settings.
*/
export default function WorkspaceComputersListGroup({ computers }: Props) {
return (
<SettingsGroup
id="computers"
title="Computers"
description="Where tasks run. The default one is used unless a bot picks another."
>
<SettingsList
is_empty={!computers.length}
empty="No computers here yet. Bots can chat, but tasks that need a computer can't run."
>
{computers.map((computer, index) => (
<WorkspaceDashboardComputerRow
key={computer.id}
computer={computer}
is_default={index == 0}
/>
))}
</SettingsList>
</SettingsGroup>
);
}
@@ -0,0 +1,39 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import type { WorkspaceBot } from "@/src/types";
type Props = {
workspace_id: number;
bot: WorkspaceBot;
};
/**
* A bot in the workspace list: its face, name and how it works here, its
* row opening its conversation.
*/
export default function WorkspaceDashboardBotRow({ workspace_id, bot }: Props) {
const name = bot.name || "This bot";
const is_coordinator = bot.kind == "coordinator";
const has_own = Boolean(bot.workspace_system_prompt || bot.workspace_memory);
return (
<SettingsListRow
avatar={
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
}
title={name}
href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
badge={is_coordinator ? "Coordinator" : undefined}
subtitle={
has_own
? "Has instructions for this workspace"
: bot.role || "Uses its own instructions here"
}
/>
);
}
@@ -0,0 +1,25 @@
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
import type { ComputerWithStatus } from "@/src/types";
import computerStatusLabel from "@/src/utils/computer-status-label";
type Props = {
computer: ComputerWithStatus;
is_default: boolean;
};
/**
* A computer in the workspace dashboard: its state and what it's for.
*/
export default function WorkspaceDashboardComputerRow({ computer, is_default }: Props) {
const name = computer.name || `Computer ${computer.id}`;
return (
<SettingsListRow
avatar={<ComputerIconTile />}
title={name}
badge={is_default ? "Default" : undefined}
subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`}
/>
);
}
@@ -0,0 +1,54 @@
import { useContext } from "react";
import { Settings } from "lucide-react";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import { AppContext } from "@/src/pages/__root";
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import WorkspaceAboutGroup from "../(partials)/workspace-about-group";
import WorkspaceActivityGroup from "../(partials)/workspace-activity-group";
import WorkspaceBotsListGroup from "../(partials)/workspace-bots-list-group";
import WorkspaceComputersListGroup from "../(partials)/workspace-computers-list-group";
/**
* A workspace's dashboard: a read-only summary of its picture and context,
* its latest activity, its bots and its computers. Everything editable
* lives under Settings, reached from the header.
*/
export default function WorkspaceDashboard() {
const { pageProps } = useContext(AppContext);
const workspace = pageProps?.workspace;
if (!workspace?.id) return null;
const workspace_id = Number(workspace.id);
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
return (
<SettingsShell
title={workspace.name || "Workspace"}
description="A workspace at a glance: what it is, what's happening, its bots and computers."
action={
can_manage ? (
<HeaderIconButton
icon={Settings}
title="Workspace settings"
href={`/admin/settings/workspaces/${workspace_id}`}
/>
) : undefined
}
>
<WorkspaceAboutGroup workspace={workspace} />
<WorkspaceActivityGroup
workspace_id={workspace_id}
activity={pageProps?.workspace_activity || []}
/>
<WorkspaceBotsListGroup
workspace_id={workspace_id}
bots={pageProps?.workspace_bots || []}
/>
<WorkspaceComputersListGroup
computers={pageProps?.workspace_computers || []}
/>
</SettingsShell>
);
}
@@ -1,8 +1,6 @@
import userAuth from "@/src/functions/backend/auth/user-auth"; import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces"; import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers"; import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import { AdminHomePath } from "@/src/dict/admin-sections-dict"; import { AdminHomePath } from "@/src/dict/admin-sections-dict";
@@ -11,9 +9,9 @@ import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types";
/** /**
* A workspace's page: its details, crew, computers and models, with the * A workspace's dashboard: its picture, context, latest activity, bots and
* rail showing its crew. Only super admins and admins manage it; a member * computers, all read-only. Editing lives under Settings. Only super admins
* opens its first bot instead. Its settings live under Settings. * and admins see it; a member opens its first bot instead.
*/ */
export const server: BunextPageServerFn<PagePropsType> = async (params) => { export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query; const query: PageQueryObject = params.query;
@@ -61,18 +59,11 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
}; };
} }
const [ const [workspace_bots, workspace_computers, workspace_activity] =
workspace_bots, await Promise.all([
all_bots,
workspace_computers,
computers,
workspace_harness_overview,
] = await Promise.all([
grabWorkspaceBots({ user_id: user.id, workspace_id }), grabWorkspaceBots({ user_id: user.id, workspace_id }),
grabBots({ user_id: user.id }),
grabWorkspaceComputers({ workspace_id }), grabWorkspaceComputers({ workspace_id }),
grabUserComputers({ user_id: user.id }), grabWorkspaceActivity({ user_id: user.id, workspace_id }),
grabBotHarnessOverview({ workspace_id }).catch(() => null),
]); ]);
return { return {
@@ -81,10 +72,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
workspace, workspace,
bots: workspace_bots, bots: workspace_bots,
workspace_bots, workspace_bots,
all_bots,
workspace_computers, workspace_computers,
computers, workspace_activity,
workspace_harness_overview,
}, },
}; };
}; };
@@ -1,10 +1,10 @@
import type { PagePropsType } from "@/src/types"; import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types"; import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data"; import { SiteData } from "@/src/data/site-data";
import WorkspaceOverview from "@/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview"; import WorkspaceDashboard from "./(sections)/workspace-dashboard";
export default function WorkspacePage() { export default function WorkspacePage() {
return <WorkspaceOverview show_settings_link />; return <WorkspaceDashboard />;
} }
export const meta: BunextPageModuleMetaFn = async (params) => { export const meta: BunextPageModuleMetaFn = async (params) => {
+17
View File
@@ -82,6 +82,19 @@ export type WorkspaceWithActivity = BUN_SQLITE_COBBLE_WORKSPACES & {
last_activity_at?: number | null; last_activity_at?: number | null;
}; };
/**
* One recent conversation in a workspace, for its dashboard: the user's
* session with the bot, and the bot it's with.
*/
export type WorkspaceActivityItem = BUN_SQLITE_COBBLE_BOT_SESSIONS & {
message_count: number;
last_message_text?: string | null;
bot_name?: string | null;
bot_image_media_id?: number | null;
bot_hue?: string | null;
bot_face?: string | null;
};
/** /**
* A computer with its container's state, for lists and the Add flow. * A computer with its container's state, for lists and the Add flow.
*/ */
@@ -392,6 +405,10 @@ export type PagePropsType = {
all_bots?: BotWithLastMessage[] | null; all_bots?: BotWithLastMessage[] | null;
workspace_computers?: ComputerWithStatus[] | null; workspace_computers?: ComputerWithStatus[] | null;
computers?: ComputerWithStatus[] | null; computers?: ComputerWithStatus[] | null;
/**
* A workspace's dashboard: the user's latest sessions there.
*/
workspace_activity?: WorkspaceActivityItem[] | null;
/** /**
* The computer a settings page manages, with its access. * The computer a settings page manages, with its access.
*/ */