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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user