From dd1e9e0cfe80701bdf300a7cc783032ce3fb0259 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 15:48:23 +0100 Subject: [PATCH] Let members view a workspace's dashboard, editing still for managers only Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../settings/(partials)/computer-list-row.tsx | 8 +++-- .../workspace-computers-list-group.tsx | 7 +++- .../workspace-dashboard-computer-row.tsx | 10 ++++-- .../(sections)/workspace-dashboard.tsx | 1 + .../bots/[bot_id]/index.server.ts | 2 +- .../workspaces/[workspace_id]/index.server.ts | 34 ++----------------- 7 files changed, 25 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4e18160..37698e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl ### Bots (the chat shell) -- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`, and on a workspace's page is sent to its first bot. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. +- **Access (the crew is shared):** bots, workspaces and computers belong to the install, not to a user (their `user_id` is only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it: `bot_users` (a worker assigned to a user, for its own chat) and `workspace_users` (a user added to a workspace, to chat with every worker in it there). `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the only one who ever sees, chats with or edits the coordinator; `admin` reaches every worker and workspace; anyone else (`member`) only their grants. `canManageCrew` (super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page, `user-access-groups.tsx`, each added or taken away at once by `api/admin/users/add-access` and `remove-access` through `setUserGrant`); the structural DB helpers check it themselves (`assertCrewManager`, `src/functions/backend/db/access/`). The SQL conditions live in `src/utils/crew-access/` (`chatBotsSql`, `workspacesSql`, `workspaceBotsSql`, `computersSql`, tested against SQLite) and back `grabChatBot` (its own chat), `grabUserBot` (anywhere: its own chat or a workspace), `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots` and `grabUserComputer(s)`. Conversations are per user (`bot_sessions.user_id`): a job runs for the user whose session it's in (`runBotJob` reads it), with that user's memory and only what they reach. A bot's face and status line are per user too (`bot_user_faces`, set by `setBotFace` for the job's user, `grabJobUserId`; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (`botFaceSql`, `src/utils/bot-face-sql.ts`). Live updates go to the session's user (`pushToSessionUser`), a face change to its user, and a bot change (its computer) to everyone who sees it (`pushBotUpdated`, `grabBotAudience`, each with their own face and active task); its status line never names a task (`BotTaskStatusLine`). Bot and workspace pictures are served to whoever sees them (`grabSharedPictureMedia`). A member with nothing granted lands on an empty `/admin/bots`; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only to `canManageCrew`. `src/scripts/share-crew.ts` (safe to rerun) moved an install from one crew per user to this. - **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `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. diff --git a/src/pages/admin/settings/(partials)/computer-list-row.tsx b/src/pages/admin/settings/(partials)/computer-list-row.tsx index ba1af95..e12a0de 100644 --- a/src/pages/admin/settings/(partials)/computer-list-row.tsx +++ b/src/pages/admin/settings/(partials)/computer-list-row.tsx @@ -17,6 +17,10 @@ type Props = { * What can be done with it, after the open button. */ children?: ReactNode; + /** + * Link the title to its settings page. Off for people who can't open it. + */ + linked?: boolean; }; /** @@ -24,14 +28,14 @@ type Props = { * settings page from the title. Every list uses it, so the row behaves the * same, and the open button shows it in the viewer over the page. */ -export default function ComputerListRow({ computer, badge, subtitle, children }: Props) { +export default function ComputerListRow({ computer, badge, subtitle, children, linked = true }: Props) { const name = computer.name || `Computer ${computer.id}`; const ready = computer.status == "running"; return ( } - href={`/admin/settings/computers/${computer.id}`} + href={linked ? `/admin/settings/computers/${computer.id}` : undefined} title={name} badge={badge} subtitle={ 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 index e315cdd..acdc854 100644 --- 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 @@ -8,13 +8,17 @@ type Props = { * The computers linked to the workspace, its default first. */ computers: ComputerWithStatus[]; + /** + * Whether the viewer can open each computer's settings page. + */ + linked: boolean; }; /** * 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) { +export default function WorkspaceComputersListGroup({ computers, linked }: Props) { return ( ))} 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 index 02e757e..a135288 100644 --- 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 @@ -4,12 +4,16 @@ import type { ComputerWithStatus } from "@/src/types"; type Props = { computer: ComputerWithStatus; is_default: boolean; + /** + * Whether the viewer can open its settings page. + */ + linked: boolean; }; /** * A computer in the workspace dashboard: its state, resources and what - * it's for, opening its settings page. + * it's for, opening its settings page for those who manage the crew. */ -export default function WorkspaceDashboardComputerRow({ computer, is_default }: Props) { - return ; +export default function WorkspaceDashboardComputerRow({ computer, is_default, linked }: Props) { + return ; } diff --git a/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx b/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx index 14339d9..73ff4eb 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/(sections)/workspace-dashboard.tsx @@ -48,6 +48,7 @@ export default function WorkspaceDashboard() { /> ); diff --git a/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts index eb0a154..26c5e46 100644 --- a/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts @@ -29,7 +29,7 @@ export const server: BunextPageServerFn = async (params) => { redirect: { destination: canManageCrew({ user_types }) ? `/admin/settings/workspaces/${query.workspace_id}` - : "/admin/bots", + : `/admin/workspaces/${query.workspace_id}`, }, }; } diff --git a/src/pages/admin/workspaces/[workspace_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/index.server.ts index 8c1e98f..ed8c2fe 100644 --- a/src/pages/admin/workspaces/[workspace_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/index.server.ts @@ -4,18 +4,17 @@ import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-wo 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"; -import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import type { PagePropsType, PageQueryObject } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; /** * 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. + * computers, all read-only. Everyone who reaches the workspace sees it; editing lives + * under Settings, for super admins and admins only. */ export const server: BunextPageServerFn = async (params) => { const query: PageQueryObject = params.query; - const { user, user_types } = await userAuth({ req: params.req }); + const { user } = await userAuth({ req: params.req }); if (!user?.id) { return { redirect: { destination: "/auth/login" } }; @@ -32,33 +31,6 @@ export const server: BunextPageServerFn = async (params) => { const workspace_id = Number(workspace.id); - /** - * Members don't manage it: they go straight to its first bot. - */ - if (!canManageCrew({ user_types })) { - const workspace_bots = await grabWorkspaceBots({ - user_id: user.id, - workspace_id, - }); - - if (workspace_bots[0]?.id) { - return { - redirect: { - destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`, - }, - }; - } - - return { - props: { - workspaces, - workspace, - bots: workspace_bots, - workspace_bots, - }, - }; - } - const [workspace_bots, workspace_computers, workspace_activity] = await Promise.all([ grabWorkspaceBots({ user_id: user.id, workspace_id }),