Add workspaces, conversation sessions, app-managed computers and pictures

- Rail: Add icon with stacked form modals (bot, workspace, computer),
  coordinator, recent bots with "See all bots", Workspaces section, and
  a workspace mode listing only its crew
- Workspaces: page for context, bots (per-workspace instructions and
  memory), computers (default, link, unlink), models and delete;
  conversations scoped per bot per workspace
- Sessions: conversations split into searchable, reopenable sessions
  with carry-over summaries; sessions and new-session header icons
- Computers: created and provisioned over the docker socket, home dir
  kept at DATA_DIR/computers/<id> after delete; super admins only
- Models: workspace settings win over a bot's own, then the defaults
- Pictures for bots and workspaces; bots editable from the header
- Coordinator tools: update_workspace, set_bot_workspace_instructions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
@@ -0,0 +1,20 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
/**
* Under `/api/admin/bots/`.
*/
path: "list-sessions" | "new-session" | "reopen-session";
body: Record<string, unknown>;
};
/**
* Posts to a conversation-session API route.
*/
export default async function postSessionApi<T extends { [k: string]: any }>({ path, body }: Params) {
return await fetchApi<Record<string, unknown>, APIResponseObject<T>>(`/api/admin/bots/${path}`, {
method: "POST",
body,
});
}
@@ -5,6 +5,7 @@ import type { BotMessageWithAttachments } from "@/src/types";
export default async function sendBotMessage({
bot_id,
workspace_id,
text,
media_ids,
}: SendBotMessageRequest) {
@@ -13,6 +14,6 @@ export default async function sendBotMessage({
APIResponseObject<BotMessageWithAttachments>
>(`/api/admin/bots/send-message`, {
method: "POST",
body: { bot_id, text, media_ids },
body: { bot_id, workspace_id, text, media_ids },
});
}
@@ -4,15 +4,15 @@ import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { SetBotComputerRequest } from "@/src/schemas/bot-schema";
/**
* Switches the computer a bot works on. Returns its computers, the new
* one first.
* Switches the computer a bot works on (in a workspace, the workspace's
* default). Returns its computers, the new one first.
*/
export default async function setBotComputer({ bot_id, computer_id }: SetBotComputerRequest) {
export default async function setBotComputer({ bot_id, computer_id, workspace_id }: SetBotComputerRequest) {
return await fetchApi<
SetBotComputerRequest,
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
>(`/api/admin/bots/set-computer`, {
method: "POST",
body: { bot_id, computer_id },
body: { bot_id, computer_id, workspace_id },
});
}
@@ -33,6 +33,10 @@ export type BotComputers = {
type Params = {
bot_id: number;
/**
* In a workspace, switching changes the workspace's default computer.
*/
workspace_id?: number | null;
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
@@ -42,7 +46,7 @@ type Params = {
* arrives as `server:bot-updated` with the bot's `computer_id`. Also holds
* the computer open in the viewer (`ComputerViewerModal`).
*/
export default function useBotComputers({ bot_id, computers: initial }: Params): BotComputers {
export default function useBotComputers({ bot_id, workspace_id, computers: initial }: Params): BotComputers {
const [computers, setComputers] = useState(initial);
const [switching_id, setSwitchingId] = useState<number>();
const [error, setError] = useState<string>();
@@ -71,7 +75,7 @@ export default function useBotComputers({ bot_id, computers: initial }: Params):
setSwitchingId(computer_id);
setError(undefined);
const res = await setBotComputer({ bot_id, computer_id }).catch(() => null);
const res = await setBotComputer({ bot_id, computer_id, workspace_id }).catch(() => null);
if (res?.success && res.payload?.length) {
setComputers(res.payload);
@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import type { BotSessionListItem } from "@/src/types";
import postSessionApi from "../(functions)/post-session-api";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Loads only while the modal is open.
*/
open: boolean;
};
/**
* A conversation's sessions, latest first, searched as the user types
* (debounced). Starting or reopening a session reloads the page onto it.
*/
export default function useBotSessions({ bot_id, workspace_id, open }: Params) {
const [query, setQuery] = useState("");
const [sessions, setSessions] = useState<BotSessionListItem[]>([]);
const [loading, setLoading] = useState(false);
const [busy_id, setBusyId] = useState<number | "new">();
const [error, setError] = useState<string>();
useEffect(() => {
if (!open) return;
let stale = false;
const timer = setTimeout(async () => {
setLoading(true);
const res = await postSessionApi<BotSessionListItem>({
path: "list-sessions",
body: { bot_id, workspace_id, query: query.trim() || undefined },
}).catch(() => null);
if (stale) return;
setLoading(false);
if (res?.success) {
setSessions(res.payload || []);
setError(undefined);
} else {
setError(res?.msg || "Couldn't load the sessions.");
}
}, query ? 250 : 0);
return () => {
stale = true;
clearTimeout(timer);
};
}, [open, query, bot_id, workspace_id]);
const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => {
if (busy_id) return;
setBusyId(id);
setError(undefined);
const res = await postSessionApi({ path, body }).catch(() => null);
if (res?.success) {
window.location.reload();
return;
}
setBusyId(undefined);
setError(res?.msg || "That didn't work.");
};
return {
query,
setQuery,
sessions,
loading,
busy_id,
error,
startSession: () => act({ id: "new", path: "new-session", body: { bot_id, workspace_id } }),
reopenSession: ({ session_id }: { session_id: number }) =>
act({ id: session_id, path: "reopen-session", body: { session_id } }),
};
}
@@ -4,6 +4,10 @@ import type { BotJobUpdateData, BotTaskWithSteps, BotUpdatedData } from "@/src/t
type Params = {
bot_id: number;
/**
* The open session: steps of jobs from other sessions are left out.
*/
session_id?: number | null;
/**
* The bot's active task when the page loaded.
*/
@@ -27,13 +31,14 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
* row's text), kept current from `server:bot-job-update`. Starts over when
* `server:bot-updated` brings a different task, or none.
*/
export default function useTaskProgress({ bot_id, task }: Params) {
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
useServerEvent({
event: "server:bot-job-update",
onData: (update: BotJobUpdateData) => {
if (update.bot_id != bot_id || !update.task_id || !update.text) return;
if (session_id && update.session_id && update.session_id != session_id) return;
setProgress({ task_id: update.task_id, text: update.text });
},
@@ -11,6 +11,11 @@ import HeaderIconButton from "./header-icon-button";
type Props = {
bot: BotWithLastMessage;
/**
* In a workspace the bot uses the workspace's computers, and a switch
* changes the workspace's default for every bot there.
*/
workspace_name?: string | null;
};
/**
@@ -18,7 +23,7 @@ type Props = {
* computers are, the one it's on checked, and with more than one,
* switches it. Each can be opened in the viewer.
*/
export default function BotComputersModal({ bot }: Props) {
export default function BotComputersModal({ bot, workspace_name }: Props) {
const { computers, switching_id, error, switchComputer, openComputer } =
useContext(BotComputersContext);
const [open, setOpen] = useState(false);
@@ -52,9 +57,13 @@ export default function BotComputersModal({ bot }: Props) {
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one. Open one to use it yourself.`}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{can_switch
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
: `This is the only computer ${name} can use right now.`}
{workspace_name
? can_switch
? `These are ${workspace_name}'s computers. Picking another makes it the default for every bot working here.`
: `This is ${workspace_name}'s only computer. Add more on the workspace's page.`
: can_switch
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
: `This is the only computer ${name} can use right now.`}
</Span>
</Stack>
<Stack
@@ -3,9 +3,15 @@ import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
import saveWorkspaceHarnessSettings from "@/src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings";
type Props = {
bot: BotWithLastMessage;
/**
* In a workspace the form saves the workspace's models, for every bot
* working there.
*/
workspace_id?: number | null;
/**
* A worker's `grabBotHarnessOverview`; for the coordinator, the
* defaults (`grabHarnessOverview`).
@@ -23,7 +29,7 @@ type Props = {
* runs on the defaults, so it saves the defaults themselves, which only a
* super admin can.
*/
export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Props) {
export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, onSaved }: Props) {
const [harnesses, setHarnesses] = useState(initial);
/**
* Remounts the form when the data changes under it.
@@ -44,6 +50,20 @@ export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Pro
harnesses={harnesses}
notice={notice}
save={async ({ settings }) => {
if (workspace_id) {
const res = await saveWorkspaceHarnessSettings({
workspace_id,
settings: settings.map((setting) => ({
...setting,
inherit: Boolean(setting.inherit),
})),
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}
if (bot.kind == "coordinator") {
const res = await postHarnessApi({
path: "save-settings",
@@ -1,6 +1,6 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import BotStone from "@/src/components/general/bot-stone";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import type { MessageGroup } from "../(utils)/group-messages";
@@ -18,7 +18,8 @@ type Props = {
export default function BotMessageGroup({ bot, group }: Props) {
return (
<Row className="w-full gap-3! flex-nowrap items-start">
<BotStone
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={28}
@@ -5,6 +5,7 @@ import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "./bot-harness-form";
@@ -12,6 +13,10 @@ import HeaderIconButton from "./header-icon-button";
type Props = {
bot: BotWithLastMessage;
/**
* The workspace the conversation is in: the modal edits its models.
*/
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
harnesses?: HarnessOverview[] | null;
onSaved: (harnesses: HarnessOverview[]) => void;
};
@@ -22,13 +27,14 @@ type Props = {
* the coordinator that form is the defaults, for super admins only;
* everyone else gets the icon disabled, its title saying why.
*/
export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
if (!harnesses?.length || !bot.id) return null;
const is_coordinator = bot.kind == "coordinator";
const in_workspace = Boolean(workspace?.id);
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
const can_edit =
!is_coordinator ||
isPassThroughUser({ user_types: pageProps?.user_types });
@@ -39,7 +45,9 @@ export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
icon={CpuAi}
title={
can_edit
? is_coordinator
? in_workspace
? `Change ${workspace?.name || "the workspace"}'s models`
: is_coordinator
? "Change the default models"
: `Change ${name}'s models`
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
@@ -60,15 +68,26 @@ export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
>
<Stack className="w-full gap-1! items-stretch pb-2">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{is_coordinator ? "Default models" : `${name}'s models`}
{in_workspace
? `Models in ${workspace?.name || "this workspace"}`
: is_coordinator
? "Default models"
: `${name}'s models`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{is_coordinator
{in_workspace
? `Every bot working in ${workspace?.name || "this workspace"} uses these, the coordinator too. Left on "Same as the default", ${name} uses its own models, else the defaults.`
: is_coordinator
? `${name} and every bot left on the default use these. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it. Providers are in Settings, under Models.`
: `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`}
</Span>
</Stack>
<BotHarnessForm bot={bot} harnesses={harnesses} onSaved={onSaved} />
<BotHarnessForm
bot={bot}
workspace_id={workspace?.id || null}
harnesses={harnesses}
onSaved={onSaved}
/>
</Modal>
);
}
@@ -0,0 +1,59 @@
import { useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
type Props = {
bot: BotWithLastMessage;
};
/**
* The header's avatar: opens the bot's profile (picture, name, job,
* colour, instructions) to edit.
*/
export default function BotProfileModal({ bot }: Props) {
const [open, setOpen] = useState(false);
const name = bot.name || "This bot";
return (
<Modal
target={
<Button
title={`Edit ${name}`}
type="button"
variant="ghost"
color="text"
aria-label={`Edit ${name}`}
className="shrink-0 p-0! rounded-default hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
</Button>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{`Edit ${name}`}</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{bot.kind == "coordinator"
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
</Span>
</Stack>
{open ? <BotForm bot={bot} onCancel={() => setOpen(false)} /> : null}
</Modal>
);
}
@@ -0,0 +1,60 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import type { BotSessionListItem } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
import relativeTime from "@/src/utils/relative-time";
type Props = {
session: BotSessionListItem;
loading?: boolean;
disabled?: boolean;
onOpen: () => void;
};
/**
* One session in the sessions modal: its title, the summary or last
* message, how long and how recent. The active one says so; any other
* reopens on click.
*/
export default function BotSessionRow({ session, loading, disabled, onOpen }: Props) {
const active = session.status == "active";
const preview = session.summary || session.last_message_text || "";
const at = session.last_activity_at || session.created_at;
return (
<Button
title={active ? "This is the current session" : `Reopen "${session.title || "Untitled"}"`}
type="button"
variant="ghost"
color="text"
loading={loading}
disabled={active || disabled}
onClick={onOpen}
className={twMerge(
"w-full justify-start! items-start! gap-0! px-3! py-2.5! rounded-default text-left text-ink dark:text-ink hover:bg-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink",
active && "bg-ink/5 opacity-100!",
)}
>
<Stack className="w-full min-w-0 gap-0.5! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="grow min-w-0 truncate text-sm font-bold leading-5 text-ink">
{session.title || "Untitled session"}
</Span>
{session.unread && !active ? (
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink">New</Span>
) : null}
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{active ? "Current" : at ? relativeTime({ at: Number(at) }) : ""}
</Span>
</Row>
<Span className="w-full truncate text-xs leading-4 text-ink-secondary">
{`${session.message_count} message${session.message_count == 1 ? "" : "s"}`}
{preview ? ` · ${messagePreview({ text: preview })}` : ""}
</Span>
</Stack>
</Button>
);
}
@@ -0,0 +1,96 @@
import { useState } from "react";
import Input from "@moduletrace/twui/form/Input";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { History, Plus } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import useBotSessions from "../(hooks)/use-bot-sessions";
import BotSessionRow from "./bot-session-row";
import HeaderIconButton from "./header-icon-button";
type Props = {
bot: BotWithLastMessage;
workspace_id?: number | null;
/**
* A bot message arrived in another session of this conversation.
*/
has_other_unread?: boolean;
};
/**
* The header's sessions icon. A conversation is split into sessions so a
* bot's context never grows without bound; this lists them, searches
* them, starts a fresh one, or reopens an old one to carry on.
*/
export default function BotSessionsModal({ bot, workspace_id, has_other_unread }: Props) {
const [open, setOpen] = useState(false);
const sessions = useBotSessions({ bot_id: Number(bot.id), workspace_id, open });
const name = bot.name || "the bot";
return (
<Modal
target={
<HeaderIconButton
icon={History}
title={has_other_unread ? "Sessions: new messages in another session" : "Sessions"}
/>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Row className="w-full gap-3! flex-nowrap items-start justify-between">
<Stack className="min-w-0 gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">Sessions</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`Each session keeps ${name}'s context short. A new one starts with a summary of the last; reopen any to pick up where you left off.`}
</Span>
</Stack>
<Button
title="Start a new session"
type="button"
size="small"
loading={sessions.busy_id == "new"}
disabled={Boolean(sessions.busy_id)}
onClick={sessions.startSession}
className="shrink-0 gap-1!"
>
<Plus size={14} aria-hidden />
New
</Button>
</Row>
<Input
id="bot-sessions-search"
title="Search sessions"
placeholder="Search titles, summaries and messages"
autoComplete="off"
defaultValue={sessions.query}
onChange={(e) => sessions.setQuery(e.target.value)}
wrapperWrapperProps={{ className: "mt-4" }}
/>
<SettingsStatus status={sessions.error ? { error: true, msg: sessions.error } : undefined} />
<Stack className="w-full mt-2 max-h-[60vh] overflow-y-auto gap-0! items-stretch">
{sessions.sessions.map((session) => (
<BotSessionRow
key={session.id}
session={session}
loading={sessions.busy_id == session.id}
disabled={Boolean(sessions.busy_id)}
onOpen={() => sessions.reopenSession({ session_id: Number(session.id) })}
/>
))}
{sessions.sessions.length || sessions.loading ? null : (
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
{sessions.query.trim() ? `No session matches "${sessions.query.trim()}".` : "No sessions yet."}
</Span>
)}
</Stack>
</Modal>
);
}
@@ -1,7 +1,7 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotStone from "@/src/components/general/bot-stone";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotHue } from "@/src/dict/bot-dict";
import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
import type { BotWithLastMessage } from "@/src/types";
@@ -36,7 +36,8 @@ export default function BotThinkingRow({ bot, progress }: Props) {
return (
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
<BotStone
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={waiting ? "asleep" : "working"}
size={28}
@@ -11,6 +11,10 @@ import ComposerSendButton from "./composer-send-button";
type Props = {
bot: BotWithLastMessage;
/**
* The workspace the conversation is in, if any.
*/
workspace_id?: number | null;
onSent: (params: { message: BotMessageWithAttachments }) => void;
};
@@ -19,7 +23,7 @@ type Props = {
* Its top rule lines up with the rail's user-row rule; picked files sit
* just above it. Files can also be pasted or dropped in.
*/
export default function Composer({ bot, onSent }: Props) {
export default function Composer({ bot, workspace_id, onSent }: Props) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
@@ -34,7 +38,12 @@ export default function Composer({ bot, onSent }: Props) {
setSending(true);
setError(undefined);
const res = await sendBotMessage({ bot_id: bot.id, text, media_ids: files.media_ids });
const res = await sendBotMessage({
bot_id: bot.id,
workspace_id,
text,
media_ids: files.media_ids,
});
setSending(false);
@@ -1,4 +1,5 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
@@ -21,6 +22,7 @@ type Props = {
* bot in chat so it carries on.
*/
export default function ComputerViewerModal({ bot }: Props) {
const { pageProps } = useContext(AppContext);
const { computers, viewing, closeComputer } = useContext(BotComputersContext);
const [handing_back, setHandingBack] = useState(false);
const [error, setError] = useState<string>();
@@ -38,6 +40,7 @@ export default function ComputerViewerModal({ bot }: Props) {
const res = await sendBotMessage({
bot_id: bot.id || 0,
workspace_id: pageProps?.workspace?.id || null,
text: `I'm done on ${name}. You can carry on.`,
media_ids: [],
}).catch(() => null);
@@ -2,17 +2,25 @@ import { useContext, useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import { CpuAi, Monitor } from "lucide-react";
import BotStone from "@/src/components/general/bot-stone";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { CpuAi, Layers, Monitor } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal";
import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderMetaItem from "./header-meta-item";
type Props = {
bot: BotWithLastMessage;
/**
* The workspace the conversation is in: its name shows under the
* bot's, and the models modal edits its models.
*/
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
has_other_unread?: boolean;
/**
* The bot's models (the defaults for the coordinator).
*/
@@ -25,7 +33,7 @@ type Props = {
* that open the computers and models modals. Kept here so a save or a
* switch shows under the name at once.
*/
export default function ConversationHeader({ bot, harnesses: initial }: Props) {
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) {
const [harnesses, setHarnesses] = useState(initial);
const { computers } = useContext(BotComputersContext);
@@ -34,16 +42,15 @@ export default function ConversationHeader({ bot, harnesses: initial }: Props) {
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
/>
<BotProfileModal bot={bot} />
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{bot.name}
</H1>
<Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
{workspace?.id ? (
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
) : null}
<HeaderMetaItem
icon={CpuAi}
label="Model"
@@ -63,10 +70,17 @@ export default function ConversationHeader({ bot, harnesses: initial }: Props) {
</Row>
</Stack>
<Row className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
<BotComputersModal bot={bot} />
<BotSessionsModal
bot={bot}
workspace_id={workspace?.id || null}
has_other_unread={has_other_unread}
/>
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
<BotModelsModal
key={bot.id}
bot={bot}
workspace={workspace}
harnesses={harnesses}
onSaved={setHarnesses}
/>
@@ -9,13 +9,17 @@ type Props = {
title: string;
disabled?: boolean;
loading?: boolean;
/**
* For a button that acts on its own; a modal's target leaves it out.
*/
onClick?: () => void;
};
/**
* A ghost icon button on the right of the conversation header. Used as a
* modal's target, so it takes no click handler of its own.
* A ghost icon button on the right of the conversation header, usually a
* modal's target.
*/
export default function HeaderIconButton({ icon: Icon, title, disabled, loading }: Props) {
export default function HeaderIconButton({ icon: Icon, title, disabled, loading, onClick }: Props) {
return (
<Button
title={title}
@@ -25,6 +29,7 @@ export default function HeaderIconButton({ icon: Icon, title, disabled, loading
color="text"
disabled={disabled}
loading={loading}
onClick={onClick}
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Icon size={18} aria-hidden />
@@ -0,0 +1,42 @@
import { useState } from "react";
import { MessageSquarePlus } from "lucide-react";
import postSessionApi from "../(functions)/post-session-api";
import HeaderIconButton from "./header-icon-button";
type Props = {
bot_id: number;
workspace_id?: number | null;
};
/**
* Beside the sessions icon: closes this session and starts a fresh one
* that carries a summary of it, then shows it.
*/
export default function NewSessionButton({ bot_id, workspace_id }: Props) {
const [loading, setLoading] = useState(false);
return (
<HeaderIconButton
icon={MessageSquarePlus}
title="New session"
loading={loading}
disabled={loading}
onClick={async () => {
setLoading(true);
const res = await postSessionApi({
path: "new-session",
body: { bot_id, workspace_id },
}).catch(() => null);
if (res?.success) {
window.location.reload();
return;
}
setLoading(false);
window.alert(res?.msg || "Couldn't start a new session.");
}}
/>
);
}
@@ -15,7 +15,8 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
/**
* The conversation column: header, bottom-anchored thread, composer, the
* thread and composer held to a readable width.
* thread and composer held to a readable width. Shows one session of the
* bot's conversation, on its own or in a workspace (`pageProps.workspace`).
* The bot's computers are shared with the header's modal and the computer
* buttons in the thread, so a switch from either shows in both; either
* can open one in the viewer.
@@ -23,32 +24,58 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
export default function Conversation() {
const { pageProps } = useContext(AppContext);
const bot = pageProps?.bot;
const workspace = pageProps?.workspace?.id ? pageProps.workspace : null;
const workspace_id = workspace?.id || null;
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
pageProps?.bot_messages || [],
);
const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null);
const [has_other_unread, setHasOtherUnread] = useState(false);
const bots = useLiveBots({
bots: pageProps?.bots || [],
open_bot_id: bot?.id || undefined,
workspace_id,
});
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
const bot_computers = useBotComputers({
bot_id: bot?.id || 0,
workspace_id,
computers: pageProps?.bot_computers || [],
});
const progress = useTaskProgress({
bot_id: bot?.id || 0,
session_id,
task: pageProps?.bot_task || null,
});
console.log(`Refreshing ...`);
/**
* A message that starts a newer session (sent here or in another tab)
* moves the thread onto it.
*/
const startsNewSession = (message: BotMessageWithAttachments) =>
Boolean(message.session_id && (!session_id || Number(message.session_id) > session_id));
useServerEvent({
event: "server:bot-message",
onData: (message: BotMessageWithAttachments) => {
if (message.bot_id != bot?.id) return;
if ((message.workspace_id || null) != workspace_id) return;
if (message.session_id && message.session_id != session_id) {
if (message.author == "user" && startsNewSession(message)) {
setSessionId(Number(message.session_id));
setMessages([message]);
} else if (message.author == "user") {
window.location.reload();
} else {
setHasOtherUnread(true);
}
return;
}
setMessages((prev) =>
prev.some((m) => m.id == message.id)
@@ -56,10 +83,10 @@ export default function Conversation() {
: [...prev, message],
);
if (message.author == "bot") {
if (message.author == "bot" && message.session_id) {
fetchApi(`/api/admin/bots/mark-read`, {
method: "POST",
body: { bot_id: message.bot_id },
body: { session_id: message.session_id },
}).catch(() => {});
}
},
@@ -72,6 +99,8 @@ export default function Conversation() {
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
<ConversationHeader
bot={live_bot}
workspace={workspace}
has_other_unread={has_other_unread}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/>
<MessageThread
@@ -81,13 +110,20 @@ export default function Conversation() {
/>
<Composer
bot={bot}
onSent={({ message }) =>
workspace_id={workspace_id}
onSent={({ message }) => {
if (startsNewSession(message) && message.session_id != session_id) {
setSessionId(Number(message.session_id));
setMessages([message]);
return;
}
setMessages((prev) =>
prev.some((m) => m.id == message.id)
? prev
: [...prev, message],
)
}
);
}}
/>
</Stack>
<ComputerViewerModal bot={live_bot} />
+7 -39
View File
@@ -1,11 +1,5 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
@@ -21,10 +15,12 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
};
}
const bots = await grabBots({ user_id: user.id });
const bot = bots.find((b) => String(b.id) === String(query.bot_id));
const props = await grabConversationProps({
user_id: Number(user.id),
bot_id: String(query.bot_id),
});
if (!bot?.id) {
if (!props) {
return {
redirect: {
destination: "/admin/bots",
@@ -32,33 +28,5 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
};
}
if (bot.unread) {
await markBotRead({ bot_id: bot.id, user_id: user.id });
bot.unread = 0;
}
/**
* The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers).
*/
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
grabBotMessages({ bot_id: bot.id }),
(bot.kind == "coordinator"
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id: user.id }),
grabActiveBotTask({ bot_id: bot.id }),
]);
return {
props: {
bots,
bot,
bot_messages,
bot_harness_overview,
bot_computers,
bot_task,
},
};
return { props };
};
@@ -1,6 +1,7 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
@@ -31,6 +32,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
props: {
bots,
bot,
workspaces: await grabUserWorkspaces({ user_id: user.id }),
bot_harness_overview: await grabBotHarnessOverview({ bot_id: bot.id }),
},
};