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,22 @@
import React from "react";
import type { AddKind } from "../(dict)/add-choices-dict";
export type AddFlow = {
/**
* Opens the Add modal: pick a bot, a workspace or a computer.
*/
openAdd: () => void;
/**
* Opens one add form straight away (the rail's "+" beside Workspaces,
* a workspace page's "New computer").
*/
openForm: (params: { kind: AddKind }) => void;
};
/**
* The Add flow, shared by the rail and the pages in the bots layout.
*/
export const AddFlowContext = React.createContext<AddFlow>({
openAdd: () => {},
openForm: () => {},
});
@@ -0,0 +1,41 @@
import { Bot, Layers, Monitor, type LucideIcon } from "lucide-react";
export const AddKinds = ["bot", "workspace", "computer"] as const;
export type AddKind = (typeof AddKinds)[number];
export type AddChoice = {
kind: AddKind;
title: string;
description: string;
Icon: LucideIcon;
/**
* Only super admins see it: a computer is a container on this host.
*/
super_admin_only?: boolean;
};
/**
* What the Add modal offers, in order.
*/
export const AddChoices: AddChoice[] = [
{
kind: "bot",
title: "Bot",
description: "A crew member with a job of its own. It can work in any number of workspaces.",
Icon: Bot,
},
{
kind: "workspace",
title: "Workspace",
description: "A place for a project: shared context, its own bots, computers and models.",
Icon: Layers,
},
{
kind: "computer",
title: "Computer",
description: "A Linux sandbox bots work in: a shell, a browser and files of its own.",
Icon: Monitor,
super_admin_only: true,
},
];
@@ -0,0 +1,20 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
/**
* Under `/api/admin/`, e.g. `workspaces/create-workspace`.
*/
path: string;
body: Record<string, unknown>;
};
/**
* Posts to an admin API route.
*/
export default async function postAdminApi<T extends { [k: string]: any } = { [k: string]: any }>({ path, body }: Params) {
return await fetchApi<Record<string, unknown>, APIResponseObject<T>>(`/api/admin/${path}`, {
method: "POST",
body,
});
}
@@ -0,0 +1,35 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import useServerEvent from "@/src/hooks/use-server-event";
import type { WorkspaceWithActivity } from "@/src/types";
type Params = {
workspaces: WorkspaceWithActivity[];
/**
* The workspace open now: its new messages don't mark it unread.
*/
open_workspace_id?: number;
};
/**
* The user's workspaces, marked unread as bot messages arrive in them.
*/
export default function useLiveWorkspaces({ workspaces, open_workspace_id }: Params) {
const [live, setLive] = useState(workspaces);
useServerEvent({
event: "server:bot-message",
onData: (message: BUN_SQLITE_COBBLE_BOT_MESSAGES) => {
if (message.author != "bot" || !message.workspace_id) return;
if (message.workspace_id == open_workspace_id) return;
setLive((prev) =>
prev.map((workspace) =>
workspace.id == message.workspace_id ? { ...workspace, unread: 1 } : workspace,
),
);
},
});
return live;
}
@@ -0,0 +1,36 @@
import { useState } from "react";
type Params = {
/**
* The record's saved picture.
*/
image_media_id?: number | string | null;
};
/**
* A picture being changed in a form: a newly picked one (as a data URL),
* or the saved one removed. `changed` says whether to save anything.
*/
export default function usePickedImage({ image_media_id }: Params) {
const [data_url, setDataUrl] = useState<string>();
const [removed, setRemoved] = useState(false);
return {
data_url,
/**
* What the preview should show: the picked one, else the saved one
* unless removed.
*/
image_media_id: removed ? null : image_media_id,
has_image: Boolean(data_url || (image_media_id && !removed)),
changed: Boolean(data_url) || (removed && Boolean(image_media_id)),
pick: ({ data_url }: { data_url: string }) => {
setDataUrl(data_url);
setRemoved(false);
},
remove: () => {
setDataUrl(undefined);
setRemoved(true);
},
};
}
@@ -0,0 +1,34 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { AddChoice } from "../(dict)/add-choices-dict";
type Props = AddChoice & {
onPick: () => void;
};
/**
* One thing the Add modal can add: icon, name and what it is.
*/
export default function AddChoiceRow({ title, description, Icon, onPick }: Props) {
return (
<Button
title={`Add a ${title.toLowerCase()}`}
type="button"
variant="ghost"
color="text"
onClick={onPick}
className="w-full justify-start! items-start! gap-3! px-3! py-3! rounded-default text-left text-ink dark:text-ink hover:bg-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink"
>
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-surface">
<Icon size={16} aria-hidden />
</Span>
<Stack className="min-w-0 gap-0! items-start">
<Span className="text-sm font-bold leading-5 text-ink">{title}</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary whitespace-normal">
{description}
</Span>
</Stack>
</Button>
);
}
@@ -0,0 +1,89 @@
import { useContext, useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import { AppContext } from "@/src/pages/__root";
import type { ComputerWithStatus } from "@/src/types";
import postAdminApi from "../(functions)/post-admin-api";
import AddFormActions from "./add-form-actions";
type Props = {
onCancel: () => void;
/**
* Gets the new computer (still being set up) and the server's note.
*/
onCreated: (params: { computer: ComputerWithStatus; msg: string; linked: boolean }) => void;
};
/**
* Creates a computer: a new sandbox container on this host, its home
* directory kept on disk. On a workspace's pages it's linked to that
* workspace too (unless unticked).
*/
export default function AddComputerForm({ onCancel, onCreated }: Props) {
const { pageProps } = useContext(AppContext);
const workspace = pageProps?.workspace;
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [link, setLink] = useState(Boolean(workspace?.id));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
setLoading(true);
setError(undefined);
const workspace_id = link && workspace?.id ? workspace.id : null;
const res = await postAdminApi<ComputerWithStatus>({
path: "computers/create-computer",
body: { name, description, workspace_id },
});
setLoading(false);
if (!res.success || !res.singleRes?.id) {
setError(res.msg || "Couldn't create the computer.");
return;
}
onCreated({ computer: res.singleRes, msg: res.msg || "Setting it up.", linked: Boolean(workspace_id) });
};
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Input
id="add-computer-name"
title="Name"
label="Name"
showLabel
placeholder="Research box"
autoComplete="off"
defaultValue={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
id="add-computer-description"
title="What it's for"
label="What it's for"
showLabel
placeholder="Browsing and downloads for the launch"
autoComplete="off"
defaultValue={description}
info="Bots see this when they pick a computer."
onChange={(e) => setDescription(e.target.value)}
/>
{workspace?.id ? (
<Checkbox
title={`Add it to ${workspace.name}`}
label={`Add it to ${workspace.name || "this workspace"}`}
checked={link}
changeHandler={setLink}
/>
) : null}
<AddFormActions submit_label="Create computer" loading={loading} error={error} onCancel={onCancel} />
</Form>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useContext, useRef, useState, type ReactNode } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import { AddFlowContext } from "../(data)/add-flow-context";
import { AddChoices, type AddKind } from "../(dict)/add-choices-dict";
import BotForm from "./bot-form";
import AddChoiceRow from "./add-choice-row";
import AddComputerForm from "./add-computer-form";
import AddModalShell from "./add-modal-shell";
import AddNotice from "./add-notice";
import AddWorkspaceForm from "./add-workspace-form";
type Props = {
children: ReactNode;
};
/**
* The Add flow for the rail and every page in the bots layout: the Add
* modal offers a bot, a workspace or (super admins) a computer; picking
* one opens its form in a modal stacked on top, and closing the form goes
* back to the choice.
*/
export default function AddFlow({ children }: Props) {
const { pageProps } = useContext(AppContext);
const [choosing, setChoosing] = useState(false);
const [form, setForm] = useState<AddKind | null>(null);
const [notice, setNotice] = useState<string>();
/**
* twui closes every open modal on Escape. While a form is open over the
* Add modal, the Add modal is remounted open instead, so only the form
* closes.
*/
const form_ref = useRef<AddKind | null>(null);
const [chooser_key, setChooserKey] = useState(0);
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
const choices = AddChoices.filter((choice) => is_super_admin || !choice.super_admin_only);
const workspace = pageProps?.workspace;
/**
* Opens over the Add modal when picked from it, so closing the form
* goes back to the choice.
*/
const openForm = ({ kind }: { kind: AddKind }) => {
setNotice(undefined);
form_ref.current = kind;
setForm(kind);
};
const close = () => {
form_ref.current = null;
setForm(null);
};
return (
<AddFlowContext.Provider value={{ openAdd: () => setChoosing(true), openForm }}>
{children}
<AddModalShell
key={chooser_key}
open={choosing}
setOpen={(open) => {
if (!open && form_ref.current) {
setChooserKey((prev) => prev + 1);
return;
}
setChoosing(open);
}}
title="Add"
description={
workspace?.id
? `New bots and computers can join ${workspace.name || "this workspace"} straight away.`
: "What would you like to add?"
}
>
<Stack className="w-full mt-3 gap-1! items-stretch">
{choices.map((choice) => (
<AddChoiceRow
key={choice.kind}
{...choice}
onPick={() => openForm({ kind: choice.kind })}
/>
))}
</Stack>
</AddModalShell>
<AddModalShell
open={form == "bot"}
setOpen={(open) => !open && close()}
title="Add a bot"
description="A crew member with a job of its own. The coordinator can hand it work."
>
{form == "bot" ? <BotForm onCancel={close} /> : null}
</AddModalShell>
<AddModalShell
open={form == "workspace"}
setOpen={(open) => !open && close()}
title="Create a workspace"
description="Bots working here get its context, its computers and its models."
>
{form == "workspace" ? <AddWorkspaceForm onCancel={close} /> : null}
</AddModalShell>
<AddModalShell
open={form == "computer" && is_super_admin}
setOpen={(open) => !open && close()}
title="Create a computer"
description="A new sandbox container on this machine. Its files are kept on disk, even after it's deleted."
>
{form != "computer" ? null : notice ? (
<AddNotice
msg={notice}
onDone={() => {
close();
setChoosing(false);
}}
/>
) : (
<AddComputerForm
onCancel={close}
onCreated={({ msg, linked }) => {
if (linked && pageProps?.url?.pathname == `/admin/workspaces/${workspace?.id}`) {
window.location.reload();
return;
}
setNotice(msg);
}}
/>
)}
</AddModalShell>
</AddFlowContext.Provider>
);
}
@@ -0,0 +1,39 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
type Props = {
submit_label: string;
loading?: boolean;
error?: string;
onCancel: () => void;
};
/**
* The foot of an add form: the error, if any, then Cancel and the submit
* button.
*/
export default function AddFormActions({ submit_label, loading, error, onCancel }: Props) {
return (
<Stack className="gap-3! items-stretch">
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
<Row className="gap-2! flex-nowrap justify-end">
<Button
title="Cancel"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={loading}
onClick={onCancel}
>
Cancel
</Button>
<Button title={submit_label} type="submit" size="small" loading={loading} disabled={loading}>
{submit_label}
</Button>
</Row>
</Stack>
);
}
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
title: string;
description?: string;
children: ReactNode;
};
/**
* The frame every Add modal shares: a title, one line on what it does, the
* content.
*/
export default function AddModalShell({ open, setOpen, title, description, children }: Props) {
return (
<Modal
open={open}
setOpen={(next) => setOpen(typeof next == "function" ? next(open) : next)}
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">{title}</H2>
{description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">{description}</Span>
) : null}
</Stack>
{children}
</Modal>
);
}
@@ -0,0 +1,25 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
type Props = {
msg: string;
onDone: () => void;
};
/**
* What an add form shows once it's done and there is nowhere to go.
*/
export default function AddNotice({ msg, onDone }: Props) {
return (
<Stack className="w-full gap-4! items-stretch pt-4">
<SettingsStatus status={{ success: true, msg }} />
<Row className="justify-end">
<Button title="Done" type="button" size="small" onClick={onDone}>
Done
</Button>
</Row>
</Stack>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import ImagePickField from "@/src/components/general/image-pick-field";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import setProfileImage from "@/src/functions/frontend/set-profile-image";
import postAdminApi from "../(functions)/post-admin-api";
import usePickedImage from "../(hooks)/use-picked-image";
import AddFormActions from "./add-form-actions";
type Props = {
onCancel: () => void;
};
/**
* Creates a workspace: its picture, name, one line on what it's for, and
* the context every bot working there gets. Opens the new workspace.
*/
export default function AddWorkspaceForm({ onCancel }: Props) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [system_prompt, setSystemPrompt] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const image = usePickedImage({});
const submit = async () => {
if (loading) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi<{ workspace_id: number }>({
path: "workspaces/create-workspace",
body: { name, description, system_prompt },
});
if (!res.success || !res.singleRes?.workspace_id) {
setLoading(false);
setError(res.msg || "Couldn't create the workspace.");
return;
}
const workspace_id = res.singleRes.workspace_id;
if (image.data_url) {
await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null);
}
window.location.href = `/admin/workspaces/${workspace_id}`;
};
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<ImagePickField
preview={<WorkspaceAvatar size="lg" src={image.data_url} />}
has_image={image.has_image}
onPick={image.pick}
onRemove={image.remove}
disabled={loading}
note="Optional. A logo or any picture that says what it is."
/>
<Input
id="add-workspace-name"
title="Name"
label="Name"
showLabel
placeholder="Spring launch"
autoComplete="off"
defaultValue={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
id="add-workspace-description"
title="What it's for"
label="What it's for"
showLabel
placeholder="Everything for the April release"
autoComplete="off"
defaultValue={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Textarea
id="add-workspace-instructions"
title="Context"
label="Context"
showLabel
rows={5}
placeholder="We ship on April 14. The repo is acme/launch. Marketing signs off on all copy."
defaultValue={system_prompt}
info="Every bot working here gets this. You can change it later."
onChange={(e) => setSystemPrompt(e.target.value)}
/>
<AddFormActions submit_label="Create workspace" loading={loading} error={error} onCancel={onCancel} />
</Form>
);
}
@@ -0,0 +1,51 @@
import { useState } from "react";
import Input from "@moduletrace/twui/form/Input";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotWithLastMessage } from "@/src/types";
import AddModalShell from "./add-modal-shell";
import AllBotsRow from "./all-bots-row";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
title: string;
bots: BotWithLastMessage[];
hrefFor: (bot: BotWithLastMessage) => string;
};
/**
* Every bot (of the crew, or of a workspace), latest activity first, with
* a search by name or job. A bot opens its conversation.
*/
export default function AllBotsModal({ open, setOpen, title, bots, hrefFor }: Props) {
const [query, setQuery] = useState("");
const search = query.trim().toLowerCase();
const found = search
? bots.filter((bot) => `${bot.name} ${bot.role || ""}`.toLowerCase().includes(search))
: bots;
return (
<AddModalShell open={open} setOpen={setOpen} title={title} description={`${bots.length} in all.`}>
<Input
id="all-bots-search"
title="Search bots"
placeholder="Search by name or job"
autoComplete="off"
defaultValue={query}
onChange={(e) => setQuery(e.target.value)}
wrapperWrapperProps={{ className: "mt-4" }}
/>
<Stack className="w-full mt-2 max-h-[60vh] overflow-y-auto gap-0! items-stretch">
{found.map((bot) => (
<AllBotsRow key={bot.id} bot={bot} href={hrefFor(bot)} />
))}
{found.length ? null : (
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
No bot matches "{query.trim()}".
</Span>
)}
</Stack>
</AddModalShell>
);
}
@@ -0,0 +1,43 @@
import Link from "@moduletrace/twui/layout/Link";
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 relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot";
type Props = {
bot: BotWithLastMessage;
href: string;
};
/**
* One bot in the all-bots modal: stone, name, job, last activity.
*/
export default function AllBotsRow({ bot, href }: Props) {
const hue = (bot.hue || null) as BotHue | null;
const at = bot.last_message_at || bot.last_activity_at;
return (
<Link
href={href}
title={bot.name}
className="flex h-14 items-center gap-3 rounded-default px-3 border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{bot.name}</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{bot.status_line || bot.role || ""}
</Span>
</Stack>
{at ? (
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(at) })}
</Span>
) : null}
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
</Link>
);
}
@@ -0,0 +1,45 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import BotStone from "@/src/components/general/bot-stone";
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
type Props = {
name: string;
role: string;
/**
* Null for the coordinator.
*/
hue: BotHue | null;
image_media_id?: number | string | null;
picked_src?: string;
};
/**
* How the bot will look in the rail as the form is filled in: its picture
* or stone, name and job, and its colour (a small stone beside the name
* when a picture hides the big one).
*/
export default function BotFormPreview({ name, role, hue, image_media_id, picked_src }: Props) {
const has_picture = Boolean(picked_src || image_media_id);
const hue_title = BotHues.find((h) => h.value == hue)?.title;
return (
<Row className="min-w-0 grow gap-3! flex-nowrap items-center">
<BotAvatar hue={hue} size={56} image_media_id={image_media_id} src={picked_src} />
<Stack className="min-w-0 gap-0!">
<Row className="min-w-0 w-full gap-1.5! flex-nowrap items-center">
<Span className="text-sm font-bold leading-5 text-ink truncate">{name.trim() || "Its name"}</Span>
{has_picture && hue ? <BotStone hue={hue} size={18} label={`${hue_title} bot`} /> : null}
</Row>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{role.trim() || "Its job"}
</Span>
{hue_title ? (
<Span className="text-[11px] leading-4 text-ink-secondary">{hue_title}</Span>
) : null}
</Stack>
</Row>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useContext, useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import Textarea from "@moduletrace/twui/form/Textarea";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import ImagePickField from "@/src/components/general/image-pick-field";
import { AppContext } from "@/src/pages/__root";
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
import setProfileImage from "@/src/functions/frontend/set-profile-image";
import postAdminApi from "../(functions)/post-admin-api";
import usePickedImage from "../(hooks)/use-picked-image";
import pickFreeHue from "../(utils)/pick-free-hue";
import AddFormActions from "./add-form-actions";
import BotFormPreview from "./bot-form-preview";
type Props = {
/**
* Edits this bot; adds a worker when not given.
*/
bot?: BUN_SQLITE_COBBLE_BOTS;
onCancel: () => void;
};
/**
* Adds or edits a bot: its picture, name, job, colour and instructions,
* with a live preview of how it'll look. A new bot on a workspace's pages
* joins that workspace too (unless unticked), then its conversation opens.
* The coordinator keeps its name and has no colour: only its picture and
* instructions change.
*/
export default function BotForm({ bot, onCancel }: Props) {
const { pageProps } = useContext(AppContext);
const workspace = pageProps?.workspace;
const crew = pageProps?.all_bots || pageProps?.bots || [];
const is_coordinator = bot?.kind == "coordinator";
const [name, setName] = useState(bot?.name || "");
const [role, setRole] = useState(bot?.role || "");
const [hue, setHue] = useState<BotHue>(
() => (bot?.hue as BotHue) || pickFreeHue({ taken: crew.map((b) => b.hue) }),
);
const [system_prompt, setSystemPrompt] = useState(bot?.system_prompt || "");
const [join, setJoin] = useState(!bot && Boolean(workspace?.id));
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const image = usePickedImage({ image_media_id: bot?.image_media_id });
const submit = async () => {
if (loading) return;
setLoading(true);
setError(undefined);
const workspace_id = join && workspace?.id ? workspace.id : null;
const res = bot?.id
? await postAdminApi<BUN_SQLITE_COBBLE_BOTS>({
path: "bots/update-bot",
body: { bot_id: bot.id, name: name || bot.name, role, hue, system_prompt },
})
: await postAdminApi<{ bot_id: number }>({
path: "bots/create-bot",
body: { name, role, hue, system_prompt, workspace_id },
});
const bot_id = bot?.id || (res.singleRes as { bot_id?: number } | undefined)?.bot_id;
if (!res.success || !bot_id) {
setLoading(false);
setError(res.msg || "Couldn't save the bot.");
return;
}
if (image.changed) {
const image_res = await setProfileImage({
target: "bot",
id: bot_id,
data_url: image.data_url || null,
}).catch(() => null);
if (!image_res?.success) {
setLoading(false);
setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image.");
return;
}
}
if (bot?.id) {
window.location.reload();
return;
}
window.location.href = workspace_id
? `/admin/workspaces/${workspace_id}/bots/${bot_id}`
: `/admin/bots/${bot_id}`;
};
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<ImagePickField
preview={
<BotFormPreview
name={name}
role={role}
hue={is_coordinator ? null : hue}
image_media_id={image.image_media_id}
picked_src={image.data_url}
/>
}
has_image={image.has_image}
onPick={image.pick}
onRemove={image.remove}
disabled={loading}
note="Optional. Without one, it's a stone in its colour."
/>
{is_coordinator ? null : (
<>
<Input
id="bot-form-name"
title="Name"
label="Name"
showLabel
placeholder="Scout"
autoComplete="off"
defaultValue={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
id="bot-form-role"
title="Job"
label="Job"
showLabel
placeholder="Finds and summarises research"
autoComplete="off"
defaultValue={role}
info="One line, shown under its name."
onChange={(e) => setRole(e.target.value)}
/>
<Select
id="bot-form-hue"
title="Colour"
label="Colour"
showLabel
options={BotHues.map((option) => ({
value: option.value,
title: option.title,
default: option.value == hue,
}))}
info="Its colour for life: how you tell it apart, even with a picture."
changeHandler={(next) => setHue(next as BotHue)}
/>
</>
)}
<Textarea
id="bot-form-instructions"
title="Instructions"
label="Instructions"
showLabel
rows={6}
placeholder="You research topics for the user. Find good sources, read them, and report back with a short summary and links."
defaultValue={system_prompt}
info="What it does and how. It refines these itself as it learns how you work."
onChange={(e) => setSystemPrompt(e.target.value)}
/>
{!bot && workspace?.id ? (
<Checkbox
title={`Put it in ${workspace.name}`}
label={`Put it in ${workspace.name || "this workspace"}`}
checked={join}
changeHandler={setJoin}
/>
) : null}
<AddFormActions
submit_label={bot ? "Save" : "Add bot"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form>
);
}
+8 -4
View File
@@ -3,7 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import BotStone from "@/src/components/general/bot-stone";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotWithLastMessage } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import messagePreview from "@/src/utils/message-preview";
@@ -12,6 +12,10 @@ import UnreadDot from "./unread-dot";
type Props = {
bot: BotWithLastMessage;
/**
* Where the row goes; the bot's own chat by default.
*/
href?: string;
selected?: boolean;
/**
* Shown when the bot has no status line, before the last-message
@@ -25,7 +29,7 @@ type Props = {
* line while it has one): no badges, spinners or state dots.
* Selected is ink at 5%, never a hue.
*/
export default function BotRow({ bot, selected, subtitle }: Props) {
export default function BotRow({ bot, href, selected, subtitle }: Props) {
const hue = (bot.hue || null) as BotHue | null;
const snippet =
bot.status_line ||
@@ -37,7 +41,7 @@ export default function BotRow({ bot, selected, subtitle }: Props) {
return (
<Link
href={`/admin/bots/${bot.id}`}
href={href || `/admin/bots/${bot.id}`}
aria-current={selected ? "page" : undefined}
title={bot.name}
className={twMerge(
@@ -47,7 +51,7 @@ export default function BotRow({ bot, selected, subtitle }: Props) {
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<BotStone hue={hue} face={(bot.face || "idle") as BotFace} />
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{bot.name}
+11 -10
View File
@@ -1,4 +1,3 @@
import Divider from "@moduletrace/twui/layout/Divider";
import type { BotWithLastMessage } from "@/src/types";
import { crewCounts, crewStatusLine } from "@/src/utils/crew-status";
import BotRow from "./bot-row";
@@ -6,6 +5,10 @@ import BotRow from "./bot-row";
type Props = {
bot: BotWithLastMessage;
bots: BotWithLastMessage[];
/**
* Its conversation in a workspace, instead of its own chat.
*/
href?: string;
selected?: boolean;
};
@@ -13,17 +16,15 @@ type Props = {
* Pinned first. Hue-less. Its subtitle is its status line, else the crew
* counts while any worker is busy or needs you, else its last message.
*/
export default function CoordinatorRow({ bot, bots, selected }: Props) {
export default function CoordinatorRow({ bot, bots, href, selected }: Props) {
const { working, needs_you } = crewCounts({ bots });
return (
<>
<BotRow
bot={bot}
selected={selected}
subtitle={working || needs_you ? crewStatusLine({ bots }) : undefined}
/>
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
</>
<BotRow
bot={bot}
href={href}
selected={selected}
subtitle={working || needs_you ? crewStatusLine({ bots }) : undefined}
/>
);
}
@@ -1,21 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
/**
* 28px add-bot control. The 1px strong border is required for WCAG 1.4.11.
* Add-bot flow is not designed yet, so this has no action.
*/
export default function NewBotButton() {
return (
<Button
title="Add a bot"
color="gray"
className="hidden lg:flex size-7 p-0! rounded-default-sm bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink border border-solid border-hairline-strong transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Span className="relative size-3" aria-hidden>
<Span className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[1.5px] bg-ink" />
<Span className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-[1.5px] bg-ink" />
</Span>
</Button>
);
}
@@ -0,0 +1,26 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AddFlowContext } from "../(data)/add-flow-context";
/**
* The rail header's Add icon: opens the Add modal (a bot, a workspace or a
* computer).
*/
export default function RailAddButton() {
const { openAdd } = useContext(AddFlowContext);
return (
<Button
title="Add a bot, workspace or computer"
aria-label="Add a bot, workspace or computer"
type="button"
variant="ghost"
color="text"
onClick={openAdd}
className="hidden lg:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Plus size={18} aria-hidden />
</Button>
);
}
+11 -8
View File
@@ -3,23 +3,26 @@ import Span from "@moduletrace/twui/layout/Span";
import { ArrowLeft } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
type Props = {
title?: string;
href?: string;
};
/**
* Top of the rail on settings pages, returning to the conversations. Same
* 48px row as a bot.
* Top of the rail on settings and workspace pages, returning to the
* conversations. Same 48px row as a bot.
*/
export default function RailBackLink() {
export default function RailBackLink({ title = "Back to bots", href = AdminHomePath }: Props) {
return (
<Link
href={AdminHomePath}
title="Back to bots"
href={href}
title={title}
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<ArrowLeft size={18} aria-hidden />
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
Back to bots
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">{title}</Span>
</Link>
);
}
@@ -0,0 +1,56 @@
import { useState, type ReactNode } from "react";
import Span from "@moduletrace/twui/layout/Span";
import { AppData } from "@/src/data/app-data";
import type { BotWithLastMessage } from "@/src/types";
import pickRailBots from "../(utils)/pick-rail-bots";
import AllBotsModal from "./all-bots-modal";
import BotRow from "./bot-row";
import RailSectionLabel from "./rail-section-label";
import RailSeeAllRow from "./rail-see-all-row";
type Props = {
title: string;
/**
* The worker bots, latest activity first.
*/
workers: BotWithLastMessage[];
selected_id?: number;
hrefFor: (bot: BotWithLastMessage) => string;
/**
* Shown when there are no bots yet.
*/
empty: string;
action?: ReactNode;
};
/**
* A section of bots in the rail: the latest `AppData.RailRecentBots` to be
* active (and any that want attention), then "See all bots" for the rest,
* which lists them all in a modal.
*/
export default function RailBotsSection({ title, workers, selected_id, hrefFor, empty, action }: Props) {
const [open, setOpen] = useState(false);
const { shown, hidden } = pickRailBots({ workers, selected_id, limit: AppData.RailRecentBots });
return (
<>
<RailSectionLabel title={title} action={action} />
{shown.map((bot) => (
<BotRow key={bot.id} bot={bot} href={hrefFor(bot)} selected={bot.id == selected_id} />
))}
{workers.length ? null : (
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
{empty}
</Span>
)}
{hidden.length ? (
<RailSeeAllRow
hidden={hidden}
label={`See all ${workers.length} bots`}
onOpen={() => setOpen(true)}
/>
) : null}
<AllBotsModal open={open} setOpen={setOpen} title={title} bots={workers} hrefFor={hrefFor} />
</>
);
}
+20 -11
View File
@@ -1,16 +1,23 @@
import type { BotWithLastMessage } from "@/src/types";
import { useContext } from "react";
import { AddFlowContext } from "../(data)/add-flow-context";
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
import CoordinatorRow from "./coordinator-row";
import BotRow from "./bot-row";
import RailBotsSection from "./rail-bots-section";
import RailSectionAddButton from "./rail-section-add-button";
import RailWorkspacesSection from "./rail-workspaces-section";
type Props = {
bots: BotWithLastMessage[];
workspaces: WorkspaceWithActivity[];
selected_id?: number;
};
/**
* The pinned coordinator, then the crew.
* The rail outside workspaces: the pinned coordinator, the crew (the
* latest few, then "See all bots"), then the workspaces.
*/
export default function RailCrewList({ bots, selected_id }: Props) {
export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
const { openForm } = useContext(AddFlowContext);
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
@@ -23,13 +30,15 @@ export default function RailCrewList({ bots, selected_id }: Props) {
selected={coordinator.id === selected_id}
/>
) : null}
{workers.map((bot) => (
<BotRow
key={bot.id}
bot={bot}
selected={bot.id === selected_id}
/>
))}
<RailBotsSection
action={<RailSectionAddButton title="Add a bot" onClick={() => openForm({ kind: "bot" })} />}
title="Bots"
workers={workers}
selected_id={selected_id}
hrefFor={(bot) => `/admin/bots/${bot.id}`}
empty="No bots yet. Add one for a job you hand out often."
/>
<RailWorkspacesSection workspaces={workspaces} />
</>
);
}
+5 -5
View File
@@ -2,16 +2,16 @@ import Row from "@moduletrace/twui/layout/Row";
import Link from "@moduletrace/twui/layout/Link";
import Wordmark from "@/src/components/general/wordmark";
import LogoIcon from "@/src/components/general/logo-icon";
import NewBotButton from "./new-bot-button";
import RailAddButton from "./rail-add-button";
type Props = {
/**
* Off on settings pages, where adding a bot is out of place.
* Off on settings pages, where adding things is out of place.
*/
show_new_bot?: boolean;
show_add?: boolean;
};
export default function RailHeader({ show_new_bot = true }: Props) {
export default function RailHeader({ show_add = true }: Props) {
return (
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
<Link
@@ -21,7 +21,7 @@ export default function RailHeader({ show_new_bot = true }: Props) {
<Wordmark height={15} className="hidden lg:block" />
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
</Link>
{show_new_bot ? <NewBotButton /> : null}
{show_add ? <RailAddButton /> : null}
</Row>
);
}
@@ -0,0 +1,26 @@
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
type Props = {
title: string;
onClick: () => void;
};
/**
* The "+" beside a rail section's label.
*/
export default function RailSectionAddButton({ title, onClick }: Props) {
return (
<Button
title={title}
aria-label={title}
type="button"
variant="ghost"
color="text"
onClick={onClick}
className="size-6 p-0! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink"
>
<Plus size={14} aria-hidden />
</Button>
);
}
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import Divider from "@moduletrace/twui/layout/Divider";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
title: string;
/**
* A small control on the right, e.g. the "+" beside Workspaces.
*/
action?: ReactNode;
};
/**
* Heads a section of the rail. When the rail collapses to icons it's a
* hairline instead.
*/
export default function RailSectionLabel({ title, action }: Props) {
return (
<>
<Row className="hidden lg:flex shrink-0 h-8 mt-3 gap-2! flex-nowrap items-center justify-between px-3">
<Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary">
{title}
</Span>
{action}
</Row>
<Divider className="lg:hidden shrink-0 mx-1 my-2 w-auto border-hairline dark:border-hairline" />
</>
);
}
@@ -0,0 +1,42 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { Ellipsis } from "lucide-react";
import { AppData } from "@/src/data/app-data";
import type { BotWithLastMessage } from "@/src/types";
import RailSeeAllStone from "./rail-see-all-stone";
type Props = {
/**
* The bots the rail doesn't list.
*/
hidden: BotWithLastMessage[];
label: string;
onOpen: () => void;
};
/**
* Under the listed bots: a few stones of the ones that aren't listed and
* "See all bots", which opens them all. An ellipsis when the rail
* collapses.
*/
export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
return (
<Button
title={label}
type="button"
variant="ghost"
color="text"
onClick={onOpen}
className="flex shrink-0 h-10 w-full items-center justify-center lg:justify-start gap-3! rounded-default px-1! lg:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Ellipsis size={18} aria-hidden className="lg:hidden" />
<Row className="hidden lg:flex shrink-0 gap-1! flex-nowrap items-center">
{hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
<RailSeeAllStone key={bot.id} bot={bot} />
))}
</Row>
<Span className="hidden lg:inline text-[13px] font-bold leading-4">{label}</Span>
</Button>
);
}
@@ -0,0 +1,21 @@
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
bot: BotWithLastMessage;
};
/**
* One small stone beside "See all bots".
*/
export default function RailSeeAllStone({ bot }: Props) {
return (
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={18}
/>
);
}
@@ -0,0 +1,58 @@
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";
import WorkspaceRow from "./workspace-row";
type Props = {
workspace: WorkspaceWithActivity;
/**
* The workspace's crew: the coordinator and its bots.
*/
bots: BotWithLastMessage[];
selected_id?: number;
/**
* On the workspace's own page rather than a conversation in it.
*/
on_workspace_page?: boolean;
};
/**
* 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.
*/
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 (
<>
<RailBackLink title="All bots" />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
{coordinator ? (
<CoordinatorRow
bot={coordinator}
bots={bots}
href={`${base}/bots/${coordinator.id}`}
selected={!on_workspace_page && coordinator.id === selected_id}
/>
) : null}
<RailBotsSection
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />}
title="Workspace bots"
workers={workers}
selected_id={on_workspace_page ? undefined : selected_id}
hrefFor={(bot) => `${base}/bots/${bot.id}`}
empty="No bots here yet. Add one, or put an existing bot in from the workspace's page."
/>
</>
);
}
@@ -0,0 +1,41 @@
import { useContext } from "react";
import Span from "@moduletrace/twui/layout/Span";
import type { WorkspaceWithActivity } from "@/src/types";
import { AddFlowContext } from "../(data)/add-flow-context";
import RailSectionAddButton from "./rail-section-add-button";
import RailSectionLabel from "./rail-section-label";
import WorkspaceRow from "./workspace-row";
type Props = {
workspaces: WorkspaceWithActivity[];
selected_id?: number;
};
/**
* The user's workspaces, with a "+" to create one.
*/
export default function RailWorkspacesSection({ workspaces, selected_id }: Props) {
const { openForm } = useContext(AddFlowContext);
return (
<>
<RailSectionLabel
title="Workspaces"
action={
<RailSectionAddButton
title="Create a workspace"
onClick={() => openForm({ kind: "workspace" })}
/>
}
/>
{workspaces.map((workspace) => (
<WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} />
))}
{workspaces.length ? null : (
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
No workspaces yet. A workspace keeps a project's context, bots and computers together.
</Span>
)}
</>
);
}
@@ -0,0 +1,39 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import { twMerge } from "tailwind-merge";
import type { WorkspaceWithActivity } from "@/src/types";
type Props = {
workspace: WorkspaceWithActivity;
selected?: boolean;
};
/**
* A workspace in the rail, the same 48px row as a bot. Workspaces have no
* hue, so unread is said in words beside the description.
*/
export default function WorkspaceRow({ workspace, selected }: Props) {
return (
<Link
href={`/admin/workspaces/${workspace.id}`}
aria-current={selected ? "page" : undefined}
title={workspace.name}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<WorkspaceAvatar image_media_id={workspace.image_media_id} className="mx-auto lg:mx-0" />
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{workspace.name}</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{workspace.unread ? "New messages" : workspace.description || ""}
</Span>
</Stack>
</Link>
);
}
+26 -6
View File
@@ -2,38 +2,58 @@ import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import useLiveBots from "@/src/hooks/use-live-bots";
import useLiveWorkspaces from "../(hooks)/use-live-workspaces";
import RailHeader from "../(partials)/rail-header";
import RailCrewList from "../(partials)/rail-crew-list";
import RailSettingsList from "../(partials)/rail-settings-list";
import RailUserRow from "../(partials)/rail-user-row";
import RailWorkspaceList from "../(partials)/rail-workspace-list";
/**
* Left rail: wordmark, then the crew (pinned coordinator first). On
* `/admin/settings/*` the crew is swapped for a back link and the settings
* sub pages. Collapses to a 64px icon rail below 1024px.
* Left rail: wordmark and Add, then the crew (pinned coordinator first,
* the latest bots, the workspaces). Inside a workspace it lists that
* workspace's crew instead; on `/admin/settings/*` a back link and the
* settings sub pages. Collapses to a 64px icon rail below 1024px.
*/
export default function BotsRail() {
const { pageProps } = useContext(AppContext);
const pathname = pageProps?.url?.pathname;
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
const workspace = pageProps?.workspace;
const in_workspace = Boolean(workspace?.id && pathname?.startsWith("/admin/workspaces/"));
const bots = useLiveBots({
bots: pageProps?.bots || [],
open_bot_id: pageProps?.bot?.id || undefined,
workspace_id: in_workspace ? workspace?.id || null : null,
});
const workspaces = useLiveWorkspaces({
workspaces: pageProps?.workspaces || [],
open_workspace_id: in_workspace ? workspace?.id || undefined : undefined,
});
const live_workspace = workspaces.find((w) => w.id == workspace?.id);
return (
<Stack
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
aria-label={is_settings_page ? "Settings" : "Bots"}
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
>
<RailHeader show_new_bot={!is_settings_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1">
<RailHeader show_add={!is_settings_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
{is_settings_page ? (
<RailSettingsList pathname={pathname} />
) : in_workspace && live_workspace ? (
<RailWorkspaceList
workspace={live_workspace}
bots={bots}
selected_id={pageProps?.bot?.id || undefined}
on_workspace_page={!pageProps?.bot?.id}
/>
) : (
<RailCrewList
bots={bots}
workspaces={workspaces}
selected_id={pageProps?.bot?.id || undefined}
/>
)}
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import pickFreeHue from "./pick-free-hue";
describe("pickFreeHue", () => {
test("takes the first hue nobody has", () => {
expect(pickFreeHue({ taken: [] })).toBe("ember");
expect(pickFreeHue({ taken: ["ember", null, "amber"] })).toBe("moss");
});
test("falls back to the least used hue", () => {
const all = ["ember", "amber", "moss", "teal", "cobalt", "violet", "rose"];
expect(pickFreeHue({ taken: [...all, ...all.filter((h) => h != "teal")] })).toBe("teal");
});
});
+22
View File
@@ -0,0 +1,22 @@
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
type Params = {
/**
* Hues the crew already has.
*/
taken: (string | null | undefined)[];
};
/**
* The first hue nobody on the crew has yet, else the least used one, so a
* new bot is told apart at a glance.
*/
export default function pickFreeHue({ taken }: Params): BotHue {
const counts = new Map<string, number>(BotHues.map((hue) => [hue.value, 0]));
for (const hue of taken) {
if (hue && counts.has(hue)) counts.set(hue, counts.get(hue)! + 1);
}
return [...BotHues].sort((a, b) => counts.get(a.value)! - counts.get(b.value)!)[0]!.value;
}
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import type { BotWithLastMessage } from "@/src/types";
import pickRailBots from "./pick-rail-bots";
const bot = (id: number, extra: Partial<BotWithLastMessage> = {}): BotWithLastMessage => ({
id,
name: `Bot ${id}`,
face: "idle",
...extra,
});
describe("pickRailBots", () => {
test("lists the most recent up to the limit and hides the rest", () => {
const { shown, hidden } = pickRailBots({ workers: [bot(1), bot(2), bot(3)], limit: 2 });
expect(shown.map((b) => b.id)).toEqual([1, 2]);
expect(hidden.map((b) => b.id)).toEqual([3]);
});
test("keeps an older bot that's open, unread or needs you", () => {
const { shown, hidden } = pickRailBots({
workers: [bot(1), bot(2), bot(3, { unread: 1 }), bot(4, { face: "needs-you" }), bot(5), bot(6)],
selected_id: 5,
limit: 1,
});
expect(shown.map((b) => b.id)).toEqual([1, 3, 4, 5]);
expect(hidden.map((b) => b.id)).toEqual([2, 6]);
});
});
@@ -0,0 +1,29 @@
import type { BotWithLastMessage } from "@/src/types";
type Params = {
/**
* The worker bots, latest activity first.
*/
workers: BotWithLastMessage[];
/**
* The open conversation's bot: always listed.
*/
selected_id?: number;
limit: number;
};
/**
* Which workers the rail lists: the `limit` most recently active, plus any
* older one that's open, unread or needs the user, so nothing that wants
* attention hides behind "See all bots". The rest go to `hidden`, in the
* same order.
*/
export default function pickRailBots({ workers, selected_id, limit }: Params) {
const shown = workers.filter(
(bot, index) =>
index < limit || bot.id == selected_id || Boolean(bot.unread) || bot.face == "needs-you",
);
const hidden = workers.filter((bot) => !shown.includes(bot));
return { shown, hidden };
}
+11 -7
View File
@@ -2,10 +2,12 @@ import React from "react";
import Row from "@moduletrace/twui/layout/Row";
import useWebSocket from "@moduletrace/twui/hooks/useWebSocket";
import BotsRail from "./(sections)/bots-rail";
import AddFlow from "./(partials)/add-flow";
/**
* Full-bleed shell for every `/admin/*` page: the rail of bot conversations,
* then the page (conversation + working-on panel, or settings). No cards, no shadows;
* Full-bleed shell for every `/admin/*` page: the rail of bot conversations
* and workspaces, then the page (a conversation, a workspace, or
* settings), inside the Add flow both can open. No cards, no shadows;
* regions are separated by 1px hairlines.
*/
export default function BotsLayout({
@@ -19,11 +21,13 @@ export default function BotsLayout({
useWebSocket({ url: "/ws" });
return (
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink">
<BotsRail />
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch">
{children}
<AddFlow>
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink">
<BotsRail />
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch">
{children}
</Row>
</Row>
</Row>
</AddFlow>
);
}