From e5747effa89696e4728a7bbe524646d43e76002d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 13:40:59 +0100 Subject: [PATCH] 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/ becomes a read-only dashboard (picture, context, latest activity, bots, computers) that links into the editable settings page. --- AGENTS.md | 8 +-- src/data/app-data.ts | 4 ++ .../db/workspaces/grab-workspace-activity.ts | 40 +++++++++++++ .../bots/(partials)/rail-workspace-list.tsx | 22 +++---- .../(sections)/workspace-overview.tsx | 24 +------- .../(partials)/workspace-about-group.tsx | 59 +++++++++++++++++++ .../(partials)/workspace-activity-group.tsx | 39 ++++++++++++ .../(partials)/workspace-activity-row.tsx | 43 ++++++++++++++ .../(partials)/workspace-bots-list-group.tsx | 39 ++++++++++++ .../workspace-computers-list-group.tsx | 38 ++++++++++++ .../workspace-dashboard-bot-row.tsx | 39 ++++++++++++ .../workspace-dashboard-computer-row.tsx | 25 ++++++++ .../(sections)/workspace-dashboard.tsx | 54 +++++++++++++++++ .../workspaces/[workspace_id]/index.server.ts | 33 ++++------- .../admin/workspaces/[workspace_id]/index.tsx | 4 +- src/types/index.ts | 17 ++++++ 16 files changed, 425 insertions(+), 63 deletions(-) create mode 100644 src/functions/backend/db/workspaces/grab-workspace-activity.ts create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-about-group.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-group.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-row.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bots-list-group.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-list-group.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-bot-row.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-computer-row.tsx create mode 100644 src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx diff --git a/AGENTS.md b/AGENTS.md index d0c36a5..079f4f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - 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`. - **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. -- **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. - **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 " 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 ""` - 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 " 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`. - **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 `-` 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/` (`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//` (`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 ", 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 -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/`. 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/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 ` 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 -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 diff --git a/src/data/app-data.ts b/src/data/app-data.ts index ddc769d..b9c0a7b 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -220,6 +220,10 @@ export const AppData = { * Stones shown beside "See all bots". */ 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. */ diff --git a/src/functions/backend/db/workspaces/grab-workspace-activity.ts b/src/functions/backend/db/workspaces/grab-workspace-activity.ts new file mode 100644 index 0000000..3a680ad --- /dev/null +++ b/src/functions/backend/db/workspaces/grab-workspace-activity.ts @@ -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 { + const res = await BunSQLite.sql({ + 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 || []; +} diff --git a/src/layouts/bots/(partials)/rail-workspace-list.tsx b/src/layouts/bots/(partials)/rail-workspace-list.tsx index 377bcc4..2a3bba7 100644 --- a/src/layouts/bots/(partials)/rail-workspace-list.tsx +++ b/src/layouts/bots/(partials)/rail-workspace-list.tsx @@ -2,7 +2,6 @@ import Divider from "@moduletrace/twui/layout/Divider"; import { useContext } from "react"; import { AddFlowContext } from "../(data)/add-flow-context"; import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types"; -import CoordinatorRow from "./coordinator-row"; import RailBackLink from "./rail-back-link"; import RailBotsSection from "./rail-bots-section"; import RailSectionAddButton from "./rail-section-add-button"; @@ -11,7 +10,8 @@ import WorkspaceRow from "./workspace-row"; type Props = { 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[]; selected_id?: number; @@ -23,28 +23,22 @@ type Props = { /** * The rail inside a workspace: back to all bots, the workspace itself (its - * page), its coordinator conversation, then the workspace's bots. Every - * conversation here runs with the workspace's context. + * page), then the workspace's bots. Every conversation here runs with the + * workspace's context. The coordinator is reached from the main rail. */ export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) { const { openForm } = useContext(AddFlowContext); - const coordinator = bots.find((bot) => bot.kind === "coordinator"); const workers = bots.filter((bot) => bot.kind !== "coordinator"); const base = `/admin/workspaces/${workspace.id}`; return ( <> - + - {coordinator ? ( - - ) : null} openForm({ kind: "bot" })} />} title="Workspace bots" diff --git a/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx index aaadc3f..8e2fafe 100644 --- a/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx +++ b/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx @@ -1,7 +1,5 @@ import { useContext, useState } from "react"; 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 canManageCrew from "@/src/utils/rbac/can-manage-crew"; 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 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 * bots sees an empty note. */ -export default function WorkspaceOverview({ show_settings_link }: Props) { +export default function WorkspaceOverview() { const { pageProps } = useContext(AppContext); const [workspace, setWorkspace] = useState(pageProps?.workspace); @@ -56,16 +47,7 @@ export default function WorkspaceOverview({ show_settings_link }: Props) { workspace.description || "Context, bots, computers and models for this workspace." } - breadcrumb_title={show_settings_link ? undefined : name} - action={ - show_settings_link ? ( - - ) : undefined - } + breadcrumb_title={name} > diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-about-group.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-about-group.tsx new file mode 100644 index 0000000..2319b7d --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-about-group.tsx @@ -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 ( + + + + + + {name} + + + {workspace.description || "No description yet."} + + + + {context ? ( + + + {context} + + + ) : ( + + No context yet. Every bot here works from its own instructions alone. + + )} + + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-group.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-group.tsx new file mode 100644 index 0000000..feea090 --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-group.tsx @@ -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 ( + + + {activity.map((item) => ( + + ))} + + + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-row.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-row.tsx new file mode 100644 index 0000000..890420b --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-activity-row.tsx @@ -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 ( + + } + 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}` : ""}`} + > + + {at ? relativeTime({ at: Number(at) }) : ""} + + + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bots-list-group.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bots-list-group.tsx new file mode 100644 index 0000000..0af18e0 --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bots-list-group.tsx @@ -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 ( + + + {bots.map((bot) => ( + + ))} + + + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-list-group.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-list-group.tsx new file mode 100644 index 0000000..e315cdd --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-computers-list-group.tsx @@ -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 ( + + + {computers.map((computer, index) => ( + + ))} + + + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-bot-row.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-bot-row.tsx new file mode 100644 index 0000000..1f5f8e4 --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-bot-row.tsx @@ -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 ( + + } + 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" + } + /> + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-computer-row.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-computer-row.tsx new file mode 100644 index 0000000..9ef7b68 --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-dashboard-computer-row.tsx @@ -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 ( + } + title={name} + badge={is_default ? "Default" : undefined} + subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`} + /> + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx b/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx new file mode 100644 index 0000000..14339d9 --- /dev/null +++ b/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx @@ -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 ( + + ) : undefined + } + > + + + + + + ); +} diff --git a/src/pages/admin/workspaces/[workspace_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/index.server.ts index 0aaa255..8c1e98f 100644 --- a/src/pages/admin/workspaces/[workspace_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/index.server.ts @@ -1,8 +1,6 @@ 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 grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity"; import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers"; 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"; /** - * A workspace's page: its details, crew, computers and models, with the - * rail showing its crew. Only super admins and admins manage it; a member - * opens its first bot instead. Its settings live under Settings. + * A workspace's dashboard: its picture, context, latest activity, bots and + * computers, all read-only. Editing lives under Settings. Only super admins + * and admins see it; a member opens its first bot instead. */ export const server: BunextPageServerFn = async (params) => { const query: PageQueryObject = params.query; @@ -61,19 +59,12 @@ export const server: BunextPageServerFn = async (params) => { }; } - const [ - workspace_bots, - all_bots, - workspace_computers, - computers, - workspace_harness_overview, - ] = await Promise.all([ - grabWorkspaceBots({ user_id: user.id, workspace_id }), - grabBots({ user_id: user.id }), - grabWorkspaceComputers({ workspace_id }), - grabUserComputers({ user_id: user.id }), - grabBotHarnessOverview({ workspace_id }).catch(() => null), - ]); + const [workspace_bots, workspace_computers, workspace_activity] = + await Promise.all([ + grabWorkspaceBots({ user_id: user.id, workspace_id }), + grabWorkspaceComputers({ workspace_id }), + grabWorkspaceActivity({ user_id: user.id, workspace_id }), + ]); return { props: { @@ -81,10 +72,8 @@ export const server: BunextPageServerFn = async (params) => { workspace, bots: workspace_bots, workspace_bots, - all_bots, workspace_computers, - computers, - workspace_harness_overview, + workspace_activity, }, }; }; diff --git a/src/pages/admin/workspaces/[workspace_id]/index.tsx b/src/pages/admin/workspaces/[workspace_id]/index.tsx index 36e7d69..0772319 100644 --- a/src/pages/admin/workspaces/[workspace_id]/index.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/index.tsx @@ -1,10 +1,10 @@ import type { PagePropsType } from "@/src/types"; import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types"; 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() { - return ; + return ; } export const meta: BunextPageModuleMetaFn = async (params) => { diff --git a/src/types/index.ts b/src/types/index.ts index c8a5f84..3836dc8 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -82,6 +82,19 @@ export type WorkspaceWithActivity = BUN_SQLITE_COBBLE_WORKSPACES & { 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. */ @@ -392,6 +405,10 @@ export type PagePropsType = { all_bots?: BotWithLastMessage[] | null; workspace_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. */