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
@@ -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 ? (
<HeaderIconButton
icon={Settings}
title="Workspace settings"
href={`/admin/settings/workspaces/${workspace_id}`}
/>
) : undefined
}
breadcrumb_title={name}
>
<WorkspaceImageGroup workspace={workspace} />
<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 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<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query;
@@ -61,19 +59,12 @@ export const server: BunextPageServerFn<PagePropsType> = 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<PagePropsType> = async (params) => {
workspace,
bots: workspace_bots,
workspace_bots,
all_bots,
workspace_computers,
computers,
workspace_harness_overview,
workspace_activity,
},
};
};
@@ -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 <WorkspaceOverview show_settings_link />;
return <WorkspaceDashboard />;
}
export const meta: BunextPageModuleMetaFn = async (params) => {