Add computer switching, crew reporting, job steps and markdown chat
- Bots have a current computer (bots.computer_id), switched from the chat header beside the model button or by clicking a computer the bot names ([computer:ID]); tasks run on it by default - search_computers runs one read-only command on all of a bot's computers; chat replies know their computers (compose-computers-note) - The coordinator can use every computer and grants or revokes crew access with assign_computer - Delegated jobs record requested_by_bot_id; the worker's result or failure is reported back in the coordinator's conversation - bots_queue_updates records each model turn of a job, pushed live and shown in the working-on panel - Live relay: every process pushes new messages, job steps and bot changes from the database to its own sockets, so work done elsewhere shows live - Bot messages render as markdown (react-markdown + remark-gfm, no raw HTML) with twui components; rail previews are plain text - Fix header button overflow in the model and computer selects - Bump @moduletrace/twui to 0.1.2 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import type { BotComputers } from "../(hooks)/use-bot-computers";
|
||||
|
||||
/**
|
||||
* The open bot's computers and the switch, shared by the header's
|
||||
* computer select and the computer buttons in its messages.
|
||||
*/
|
||||
export const BotComputersContext = React.createContext<BotComputers>({
|
||||
computers: [],
|
||||
switchComputer: async () => {},
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Components } from "react-markdown";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import H4 from "@moduletrace/twui/layout/H4";
|
||||
import H5 from "@moduletrace/twui/layout/H5";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import MarkdownCodeBlock from "../(partials)/markdown-code-block";
|
||||
import MarkdownLink from "../(partials)/markdown-link";
|
||||
import MarkdownList from "../(partials)/markdown-list";
|
||||
|
||||
const HeadingClassName = "my-0! text-[15px]! leading-6! tracking-normal! font-bold text-ink";
|
||||
|
||||
/**
|
||||
* How a bot message's markdown renders: chat-sized twui elements (headings
|
||||
* no bigger than the bot's name), lists through twui's `List`, code on the
|
||||
* sunken surface, and links that open in a new tab or switch computers.
|
||||
* Tables (GFM) have no children-based twui equivalent, so they're styled
|
||||
* elements.
|
||||
*/
|
||||
export const MessageMarkdownComponents: Components = {
|
||||
p: ({ children }) => (
|
||||
<P noMargin className="text-sm leading-5 text-ink">
|
||||
{children}
|
||||
</P>
|
||||
),
|
||||
h1: ({ children }) => <H3 className={HeadingClassName}>{children}</H3>,
|
||||
h2: ({ children }) => <H3 className={HeadingClassName}>{children}</H3>,
|
||||
h3: ({ children }) => <H4 className={HeadingClassName}>{children}</H4>,
|
||||
h4: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
|
||||
h5: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
|
||||
h6: ({ children }) => <H5 className={HeadingClassName}>{children}</H5>,
|
||||
strong: ({ children }) => <Span className="font-bold">{children}</Span>,
|
||||
em: ({ children }) => <Span className="italic">{children}</Span>,
|
||||
del: ({ children }) => <Span className="line-through">{children}</Span>,
|
||||
a: MarkdownLink,
|
||||
ul: ({ children }) => <MarkdownList>{children}</MarkdownList>,
|
||||
ol: ({ children, start }) => (
|
||||
<MarkdownList ordered start={start}>
|
||||
{children}
|
||||
</MarkdownList>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink break-words">
|
||||
{children}
|
||||
</Span>
|
||||
),
|
||||
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
|
||||
blockquote: ({ children }) => (
|
||||
<Stack className="gap-2! items-stretch pl-3 border-0 border-l-2 border-solid border-hairline-strong text-ink-secondary">
|
||||
{children}
|
||||
</Stack>
|
||||
),
|
||||
hr: () => <Divider className="my-1 border-hairline dark:border-hairline" />,
|
||||
img: ({ src, alt }) => (
|
||||
<MarkdownLink href={typeof src == "string" ? src : undefined}>{alt || "Image"}</MarkdownLink>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<Stack className="w-full max-w-full overflow-x-auto items-start">
|
||||
<table className="text-[13px] leading-5 text-ink border-collapse">{children}</table>
|
||||
</Stack>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-1.5 text-left font-bold border border-solid border-hairline">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-1.5 align-top border border-solid border-hairline">{children}</td>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import type { SetBotComputerRequest } from "@/src/schemas/bot-schema";
|
||||
|
||||
/**
|
||||
* Switches the computer a bot works on. Returns its computers, the new
|
||||
* one first.
|
||||
*/
|
||||
export default async function setBotComputer({ bot_id, computer_id }: SetBotComputerRequest) {
|
||||
return await fetchApi<
|
||||
SetBotComputerRequest,
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
|
||||
>(`/api/admin/bots/set-computer`, {
|
||||
method: "POST",
|
||||
body: { bot_id, computer_id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { BotUpdatedData } from "@/src/types";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import setBotComputer from "../(functions)/set-bot-computer";
|
||||
|
||||
export type BotComputers = {
|
||||
/**
|
||||
* The bot's computers, the one it's on first.
|
||||
*/
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
/**
|
||||
* The computer being switched to, while the switch runs.
|
||||
*/
|
||||
switching_id?: number;
|
||||
error?: string;
|
||||
switchComputer: (params: { computer_id: number }) => Promise<void>;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps the open bot's computers and switches the one it's on, in place:
|
||||
* no reload, the list comes back reordered. A switch made in another tab
|
||||
* arrives as `server:bot-updated` with the bot's `computer_id`.
|
||||
*/
|
||||
export default function useBotComputers({ bot_id, computers: initial }: Params): BotComputers {
|
||||
const [computers, setComputers] = useState(initial);
|
||||
const [switching_id, setSwitchingId] = useState<number>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-updated",
|
||||
onData: (data: BotUpdatedData) => {
|
||||
const computer_id = data.computer_id;
|
||||
|
||||
if (data.id != bot_id || !computer_id) return;
|
||||
|
||||
setComputers((prev) => {
|
||||
const picked = prev.find((c) => c.id == computer_id);
|
||||
|
||||
return !picked || prev[0]?.id == computer_id
|
||||
? prev
|
||||
: [picked, ...prev.filter((c) => c.id != computer_id)];
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const switchComputer = async ({ computer_id }: { computer_id: number }) => {
|
||||
if (switching_id || computers[0]?.id == computer_id) return;
|
||||
|
||||
setSwitchingId(computer_id);
|
||||
setError(undefined);
|
||||
|
||||
const res = await setBotComputer({ bot_id, computer_id }).catch(() => null);
|
||||
|
||||
if (res?.success && res.payload?.length) {
|
||||
setComputers(res.payload);
|
||||
} else {
|
||||
setError(res?.msg || "Couldn't switch computers");
|
||||
}
|
||||
|
||||
setSwitchingId(undefined);
|
||||
};
|
||||
|
||||
return { computers, switching_id, error, switchComputer };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Check } from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
|
||||
type Props = {
|
||||
computer: BUN_SQLITE_COBBLE_COMPUTERS;
|
||||
/**
|
||||
* The computer the bot is on.
|
||||
*/
|
||||
current: boolean;
|
||||
onPick: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One computer in the header's computer select, checked when it's the
|
||||
* one the bot is on.
|
||||
*/
|
||||
export default function BotComputerOption({ computer, current, onPick }: Props) {
|
||||
return (
|
||||
<Row
|
||||
role="menuitemradio"
|
||||
aria-checked={current}
|
||||
tabIndex={0}
|
||||
onClick={current ? undefined : onPick}
|
||||
onKeyDown={(e) => {
|
||||
if (!current && (e.key == "Enter" || e.key == " ")) {
|
||||
e.preventDefault();
|
||||
onPick();
|
||||
}
|
||||
}}
|
||||
className={`w-full min-h-9 gap-2.5! flex-nowrap items-center px-4 py-1.5 text-ink transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink ${
|
||||
current ? "cursor-default" : "cursor-pointer hover:bg-sunken"
|
||||
}`}
|
||||
>
|
||||
<Check size={16} aria-hidden className={`shrink-0 ${current ? "" : "invisible"}`} />
|
||||
<Stack className="min-w-0 gap-0!">
|
||||
<Span className="text-sm leading-5 text-ink truncate w-full">
|
||||
{computer.name || `Computer ${computer.id}`}
|
||||
</Span>
|
||||
{computer.description ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
|
||||
{computer.description}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Dropdown from "@moduletrace/twui/elements/Dropdown";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronDown, Monitor } from "lucide-react";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import BotComputerOption from "./bot-computer-option";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header's computer button, beside the model button: shows the
|
||||
* computer the bot is on and, with more than one, switches it.
|
||||
*/
|
||||
export default function BotComputerSelect({ bot }: Props) {
|
||||
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const current = computers[0];
|
||||
|
||||
if (!current?.id) return null;
|
||||
|
||||
const name = bot.name || "This bot";
|
||||
const can_switch = computers.length > 1;
|
||||
|
||||
const target = (
|
||||
<Button
|
||||
title={
|
||||
can_switch
|
||||
? `${name} is on ${current.name}. Switch computers`
|
||||
: `${name} is on ${current.name}, its only computer`
|
||||
}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={!can_switch}
|
||||
loading={Boolean(switching_id)}
|
||||
buttonContentProps={{ className: "min-w-0" }}
|
||||
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
|
||||
>
|
||||
<Monitor size={16} aria-hidden className="shrink-0 -mt-0.5" />
|
||||
<Span className="truncate">{current.name || "Computer"}</Span>
|
||||
{can_switch ? <ChevronDown size={14} aria-hidden className="shrink-0" /> : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!can_switch) {
|
||||
return <Stack className="max-w-[200px] min-w-0 shrink">{target}</Stack>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
target={target}
|
||||
position="bottom-right"
|
||||
externalOpen={open}
|
||||
externalSetOpen={setOpen}
|
||||
targetWrapperProps={{ className: "max-w-full min-w-0" }}
|
||||
className="max-w-[200px] min-w-0 shrink items-stretch"
|
||||
>
|
||||
<Paper
|
||||
role="menu"
|
||||
aria-label={`${name}'s computers`}
|
||||
className="w-[260px] p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
|
||||
>
|
||||
{computers.map((computer, index) => (
|
||||
<BotComputerOption
|
||||
key={computer.id}
|
||||
computer={computer}
|
||||
current={index == 0}
|
||||
onPick={async () => {
|
||||
setOpen(false);
|
||||
await switchComputer({ computer_id: computer.id || 0 });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
<Span role="alert" className="px-4 py-2 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{error}
|
||||
</Span>
|
||||
) : null}
|
||||
</Paper>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
import NeedsYouChip from "./needs-you-chip";
|
||||
|
||||
type Props = {
|
||||
@@ -7,12 +10,19 @@ type Props = {
|
||||
needs_you?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One bot message, rendered from markdown (GFM; raw HTML is never
|
||||
* rendered). Computers it names (`computerRef`) show as buttons that
|
||||
* switch the bot to them.
|
||||
*/
|
||||
export default function BotMessageLine({ text, needs_you }: Props) {
|
||||
return (
|
||||
<Stack className="gap-1.5! items-start max-w-full">
|
||||
<P noMargin className="text-sm leading-5 text-ink whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</P>
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={MessageMarkdownComponents}>
|
||||
{linkComputerRefs({ text })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
{needs_you ? <NeedsYouChip /> : null}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={!can_edit}
|
||||
buttonContentProps={{ className: "min-w-0" }}
|
||||
className="max-w-full min-w-0 gap-2! flex-nowrap text-[13px] text-ink-secondary hover:text-ink"
|
||||
>
|
||||
<CpuAi size={16} aria-hidden className="shrink-0 -mt-0.5" />
|
||||
@@ -61,7 +62,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
|
||||
|
||||
if (!can_edit) {
|
||||
return (
|
||||
<Stack className="ml-auto max-w-[40%] min-w-0 shrink">
|
||||
<Stack className="max-w-[280px] min-w-0 shrink">
|
||||
{target}
|
||||
</Stack>
|
||||
);
|
||||
@@ -71,7 +72,7 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
|
||||
<Modal
|
||||
target={target}
|
||||
targetWrapperProps={{
|
||||
className: "ml-auto max-w-[40%] min-w-0 shrink",
|
||||
className: "max-w-[280px] min-w-0 shrink",
|
||||
}}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useContext } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Check, Monitor } from "lucide-react";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
|
||||
type Props = {
|
||||
computer_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A computer the bot named in a message (`computerRef`): a button that
|
||||
* switches the bot to it at once, or a checked chip when it's already on
|
||||
* it. A computer the bot can't use (any more) is shown by name only.
|
||||
*/
|
||||
export default function ComputerRefChip({ computer_id }: Props) {
|
||||
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
|
||||
|
||||
const index = computers.findIndex((c) => c.id == computer_id);
|
||||
const computer = computers[index];
|
||||
const name = computer?.name || `computer ${computer_id}`;
|
||||
|
||||
if (!computer) {
|
||||
return <Span className="font-bold">{name}</Span>;
|
||||
}
|
||||
|
||||
if (index == 0) {
|
||||
return (
|
||||
<Span
|
||||
title="The computer this bot is on"
|
||||
className="inline-flex h-6 gap-1.5 items-center px-2 align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap"
|
||||
>
|
||||
<Monitor size={14} aria-hidden />
|
||||
{name}
|
||||
<Check size={14} aria-hidden />
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title={error && switching_id == undefined ? error : `Switch to ${name}`}
|
||||
loading={switching_id == computer_id}
|
||||
disabled={Boolean(switching_id)}
|
||||
onClick={() => switchComputer({ computer_id })}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
||||
>
|
||||
<Monitor size={14} aria-hidden className="shrink-0" />
|
||||
{name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import { crewStatusSentence } from "@/src/utils/crew-status";
|
||||
import BotComputerSelect from "./bot-computer-select";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
|
||||
type Props = {
|
||||
@@ -19,8 +20,9 @@ type Props = {
|
||||
|
||||
/**
|
||||
* 64px header. The coordinator's status is the crew sentence. On the
|
||||
* right, the model button that opens the bot's models (the defaults on
|
||||
* the coordinator, for super admins).
|
||||
* right, the computer the bot is on (`BotComputerSelect`), then the model
|
||||
* button that opens the bot's models (the defaults on the coordinator,
|
||||
* for super admins).
|
||||
*/
|
||||
export default function ConversationHeader({ bot, bots, harnesses }: Props) {
|
||||
const status =
|
||||
@@ -43,7 +45,10 @@ export default function ConversationHeader({ bot, bots, harnesses }: Props) {
|
||||
{status}
|
||||
</Span>
|
||||
</Stack>
|
||||
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} />
|
||||
<Row className="ml-auto min-w-0 shrink gap-2! flex-nowrap justify-end">
|
||||
<BotComputerSelect bot={bot} />
|
||||
<BotModelsModal key={bot.id} bot={bot} harnesses={harnesses} />
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isValidElement, type ReactNode } from "react";
|
||||
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
|
||||
|
||||
/**
|
||||
* A fenced code block in a bot message, on the sunken surface with
|
||||
* twui's copy button (its icon is drawn white; recoloured to the theme).
|
||||
*/
|
||||
export default function MarkdownCodeBlock({ children }: { children?: ReactNode }) {
|
||||
const code = isValidElement(children)
|
||||
? (children.props as { children?: ReactNode }).children
|
||||
: children;
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
singleBlock
|
||||
backgroundColor="var(--color-sunken)"
|
||||
wrapperProps={{
|
||||
className:
|
||||
"max-w-full! outline-hairline dark:outline-hairline [&_.twui-code-block-header_svg]:stroke-ink-secondary",
|
||||
}}
|
||||
className="font-mono text-[13px] leading-5 text-ink py-2 pl-2 pr-10"
|
||||
>
|
||||
{code}
|
||||
</CodeBlock>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import { ComputerRefHrefPrefix } from "@/src/utils/link-computer-refs";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
|
||||
/**
|
||||
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
|
||||
* becomes its switch button; anything else opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (href?.startsWith(ComputerRefHrefPrefix)) {
|
||||
const computer_id = Number(href.slice(ComputerRefHrefPrefix.length));
|
||||
|
||||
if (computer_id) return <ComputerRefChip computer_id={computer_id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-ink underline underline-offset-2 hover:opacity-80 break-words"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import List from "@moduletrace/twui/layout/List";
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
ordered?: boolean;
|
||||
start?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list in a bot message, through twui's `List` (which takes items, not
|
||||
* `li` children): each item's content is lifted out of its `li`.
|
||||
*/
|
||||
export default function MarkdownList({ children, ordered, start }: Props) {
|
||||
const items = Children.toArray(children)
|
||||
.filter(isValidElement)
|
||||
.map((item) => ({
|
||||
content: (item.props as { children?: ReactNode }).children,
|
||||
}));
|
||||
|
||||
return (
|
||||
<List
|
||||
ordered={ordered}
|
||||
start={start}
|
||||
items={items}
|
||||
className={`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import { AppContext } from "@/src/pages/__root";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import useLiveBots from "@/src/hooks/use-live-bots";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import useBotComputers from "../(hooks)/use-bot-computers";
|
||||
import ConversationHeader from "../(partials)/conversation-header";
|
||||
import MessageThread from "../(partials)/message-thread";
|
||||
import Composer from "../(partials)/composer";
|
||||
|
||||
/**
|
||||
* The 900px conversation column: header, bottom-anchored thread, composer.
|
||||
* The bot's computers are shared with the header's select and the computer
|
||||
* buttons in the thread, so a switch from either shows in both.
|
||||
*/
|
||||
export default function Conversation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -22,6 +26,10 @@ export default function Conversation() {
|
||||
|
||||
const bots = useLiveBots({ bots: pageProps?.bots || [], open_bot_id: bot?.id || undefined });
|
||||
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
|
||||
const bot_computers = useBotComputers({
|
||||
bot_id: bot?.id || 0,
|
||||
computers: pageProps?.bot_computers || [],
|
||||
});
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-message",
|
||||
@@ -44,21 +52,23 @@ export default function Conversation() {
|
||||
if (!bot?.id || !live_bot) return null;
|
||||
|
||||
return (
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
bots={bots}
|
||||
harnesses={pageProps?.bot_harness_overview?.harnesses}
|
||||
/>
|
||||
<MessageThread bot={live_bot} messages={messages} />
|
||||
<Composer
|
||||
bot={bot}
|
||||
onSent={({ message }) =>
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
bots={bots}
|
||||
harnesses={pageProps?.bot_harness_overview?.harnesses}
|
||||
/>
|
||||
<MessageThread bot={live_bot} messages={messages} />
|
||||
<Composer
|
||||
bot={bot}
|
||||
onSent={({ message }) =>
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</BotComputersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,17 @@ import Span from "@moduletrace/twui/layout/Span";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { BotUpdatedData } from "@/src/types";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotJobUpdateData, BotUpdatedData } from "@/src/types";
|
||||
import TaskStepRow from "../(partials)/task-step-row";
|
||||
import OpenComputerButton from "../(partials)/open-computer-button";
|
||||
|
||||
/**
|
||||
* 280px panel listing the steps the bot is taking, kept current by the
|
||||
* active task each `server:bot-updated` push carries. Hidden below 1280px.
|
||||
* active task each `server:bot-updated` push carries. Without planned
|
||||
* steps it shows what the task's job has done (`bots_queue_updates`), the
|
||||
* newest as in progress, appended live from `server:bot-job-update`.
|
||||
* Hidden below 1280px.
|
||||
*/
|
||||
export default function WorkingOnPanel() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -24,6 +28,17 @@ export default function WorkingOnPanel() {
|
||||
},
|
||||
});
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-job-update",
|
||||
onData: (data: BotJobUpdateData) => {
|
||||
setTask((prev) =>
|
||||
prev?.id && prev.id == data.task_id && !prev.updates.some((u) => u.id == data.id)
|
||||
? { ...prev, updates: [...prev.updates, data].slice(-AppData.BotJobUpdatesShown) }
|
||||
: prev,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="hidden xl:flex w-[280px] shrink-0 h-full gap-0! items-stretch px-6 border-0 border-l border-solid border-hairline"
|
||||
@@ -38,13 +53,21 @@ export default function WorkingOnPanel() {
|
||||
{task.title}
|
||||
</H2>
|
||||
<Stack className="mt-4 gap-0! items-stretch">
|
||||
{task.steps.map((step) => (
|
||||
<TaskStepRow
|
||||
key={step.id}
|
||||
label={step.label || ""}
|
||||
state={step.state || "pending"}
|
||||
/>
|
||||
))}
|
||||
{task.steps.length
|
||||
? task.steps.map((step) => (
|
||||
<TaskStepRow
|
||||
key={step.id}
|
||||
label={step.label || ""}
|
||||
state={step.state || "pending"}
|
||||
/>
|
||||
))
|
||||
: task.updates.map((update, index) => (
|
||||
<TaskStepRow
|
||||
key={update.id}
|
||||
label={update.text || ""}
|
||||
state={index == task.updates.length - 1 ? "active" : "done"}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
|
||||
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
|
||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
@@ -40,13 +41,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
* The header's models modal: a worker's own settings, or for the
|
||||
* coordinator the defaults it runs on (without the providers).
|
||||
*/
|
||||
const [bot_messages, bot_task, bot_harness_overview] = await Promise.all([
|
||||
const [bot_messages, bot_task, bot_harness_overview, bot_computers] = await Promise.all([
|
||||
grabBotMessages({ bot_id: bot.id }),
|
||||
grabActiveBotTask({ bot_id: bot.id }),
|
||||
(bot.kind == "coordinator"
|
||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||
: grabBotHarnessOverview({ bot_id: bot.id })
|
||||
).catch(() => null),
|
||||
grabBotComputers({ bot_id: bot.id, user_id: user.id }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -56,6 +58,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
bot_messages,
|
||||
bot_task,
|
||||
bot_harness_overview,
|
||||
bot_computers,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user