Open computers from the chat, let bots ask for a take-over, show task progress

- Serve each computer to its owner under /computers/<id>/ (HTTP and
  websockets proxied to the AIO sandbox) and show it in a shared
  ComputerView with browser, terminal and code modes, opened from the
  computers modal or a take-over button in the thread.
- ask_user_to_take_over lets a bot hand a computer to the user for a
  login or captcha; the message gets a take-over button and needs-you,
  and the viewer hands it back with a chat message.
- While a task runs the thread shows "<bot> is working..." with the
  task's latest step; steps read as plain words, not tool names.
- A coordinator with no workers no longer claims to delegate, and bots
  describe started tasks in the first person.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 05:35:44 +01:00
co-authored by Claude Opus 5.5
parent b1023ba8ff
commit 56daf0c462
44 changed files with 1289 additions and 75 deletions
@@ -1,7 +1,8 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Check } from "lucide-react";
import { AppWindow, Check } from "lucide-react";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
type Props = {
@@ -11,14 +12,21 @@ type Props = {
*/
current: boolean;
onPick: () => void;
/**
* Opens the computer in the viewer.
*/
onOpen: () => void;
};
/**
* One computer in the header's computers modal, checked when it's the
* one the bot is on.
* one the bot is on, with a button that opens it.
*/
export default function BotComputerOption({ computer, current, onPick }: Props) {
export default function BotComputerOption({ computer, current, onPick, onOpen }: Props) {
const name = computer.name || `Computer ${computer.id}`;
return (
<Row className="w-full gap-1! flex-nowrap items-center pr-2 hover:bg-sunken transition-colors duration-[var(--m-fast)]">
<Row
role="radio"
aria-checked={current}
@@ -30,14 +38,14 @@ export default function BotComputerOption({ computer, current, onPick }: Props)
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"
className={`grow min-w-0 min-h-9 gap-2.5! flex-nowrap items-center px-4 py-1.5 text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink ${
current ? "cursor-default" : "cursor-pointer"
}`}
>
<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}`}
{name}
</Span>
{computer.description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
@@ -46,5 +54,17 @@ export default function BotComputerOption({ computer, current, onPick }: Props)
) : null}
</Stack>
</Row>
<Button
type="button"
variant="ghost"
color="text"
title={`Open ${name}`}
aria-label={`Open ${name}`}
onClick={onOpen}
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink rounded-default focus-visible:ring-2 focus-visible:ring-ink"
>
<AppWindow size={16} aria-hidden />
</Button>
</Row>
);
}
@@ -16,10 +16,11 @@ type Props = {
/**
* The header's computer icon, beside the model icon: opens what the bot's
* computers are, the one it's on checked, and with more than one,
* switches it.
* switches it. Each can be opened in the viewer.
*/
export default function BotComputersModal({ bot }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const { computers, switching_id, error, switchComputer, openComputer } =
useContext(BotComputersContext);
const [open, setOpen] = useState(false);
const current = computers[0];
@@ -48,7 +49,7 @@ export default function BotComputersModal({ bot }: Props) {
{can_switch ? `${name}'s computers` : `${name}'s computer`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one.`}
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one. Open one to use it yourself.`}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{can_switch
@@ -69,6 +70,10 @@ export default function BotComputersModal({ bot }: Props) {
onPick={async () => {
await switchComputer({ computer_id: computer.id || 0 });
}}
onOpen={() => {
setOpen(false);
openComputer({ computer_id: computer.id || 0, mode: "browser" });
}}
/>
))}
</Stack>
@@ -1,5 +1,6 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotStone from "@/src/components/general/bot-stone";
import type { BotHue } from "@/src/dict/bot-dict";
import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
@@ -7,28 +8,49 @@ import type { BotWithLastMessage } from "@/src/types";
type Props = {
bot: BotWithLastMessage;
/**
* The latest step of the bot's active task.
*/
progress?: string | null;
};
/**
* Under the last message while the bot is working on a reply (or waiting
* for a model to come back), so a slow reply doesn't look like none.
* Under the last message while the bot is working on a reply or a task
* (or waiting for a model to come back), so slow work doesn't look like
* none. A task shows its latest step, else its title.
*/
export default function BotThinkingRow({ bot }: Props) {
export default function BotThinkingRow({ bot, progress }: Props) {
const name = bot.name || "The bot";
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle;
const working = bot.face == "working" && !thinking;
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine;
if (!thinking && !waiting) return null;
if (!thinking && !working && !waiting) return null;
const label = thinking
? `${name} is thinking…`
: working
? `${name} is working…`
: `${BotWaitingStatusLine}…`;
const detail = working ? progress || bot.status_line : null;
return (
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={thinking ? "working" : "asleep"}
face={waiting ? "asleep" : "working"}
size={28}
/>
<Span className="text-sm leading-5 text-ink-secondary animate-pulse motion-reduce:animate-none">
{thinking ? `${bot.name || "The bot"} is thinking…` : `${BotWaitingStatusLine}…`}
</Span>
<Stack className="min-w-0 gap-0! items-start">
<Span className="text-sm leading-5 text-ink-secondary animate-pulse motion-reduce:animate-none">
{label}
</Span>
{detail ? (
<Span className="w-full text-xs leading-4 text-ink-secondary truncate">
{detail}
</Span>
) : null}
</Stack>
</Row>
);
}
@@ -0,0 +1,98 @@
import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import ComputerView from "@/src/components/general/computer-view";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import sendBotMessage from "../(functions)/send-bot-message";
type Props = {
bot: BotWithLastMessage;
};
/**
* The computer the user opened (from the computers modal, or a take-over
* the bot asked for), large, in its browser, terminal or code editor.
* After a take-over the user hands it back from here, which tells the
* bot in chat so it carries on.
*/
export default function ComputerViewerModal({ bot }: Props) {
const { computers, viewing, closeComputer } = useContext(BotComputersContext);
const [handing_back, setHandingBack] = useState(false);
const [error, setError] = useState<string>();
const computer = computers.find((c) => c.id == viewing?.computer_id);
if (!viewing || !computer?.id) return null;
const name = computer.name || `Computer ${computer.id}`;
const bot_name = bot.name || "the bot";
const handBack = async () => {
setHandingBack(true);
setError(undefined);
const res = await sendBotMessage({
bot_id: bot.id || 0,
text: `I'm done on ${name}. You can carry on.`,
media_ids: [],
}).catch(() => null);
setHandingBack(false);
if (res?.success) closeComputer();
else setError(res?.msg || "Couldn't tell the bot");
};
return (
<Modal
open
setOpen={(open) => {
if (!open) closeComputer();
}}
className="max-w-[1280px] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
>
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 gap-0! items-start">
<H2 className="text-base! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{name}
</H2>
{viewing.take_over ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`${bot_name} asked you to take over. Hand it back when you're done.`}
</Span>
) : null}
</Stack>
{viewing.take_over ? (
<Stack className="ml-auto shrink-0 gap-1! items-end">
<Button
type="button"
size="small"
loading={handing_back}
onClick={handBack}
title={`Tell ${bot_name} you're done`}
>
{`Hand back to ${bot_name}`}
</Button>
{error ? (
<Span role="alert" className="text-[13px] leading-[18px] text-ink-secondary">
{error}
</Span>
) : null}
</Stack>
) : null}
</Row>
<ComputerView
key={`${computer.id}-${viewing.mode}`}
computer_id={computer.id}
name={name}
default_mode={viewing.mode}
className="grow"
/>
</Modal>
);
}
@@ -1,11 +1,14 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import { ComputerRefHrefPrefix } from "@/src/utils/link-computer-refs";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import { ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
import ComputerRefChip from "./computer-ref-chip";
import TakeOverChip from "./take-over-chip";
/**
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button; anything else opens in a new tab.
* becomes its switch button, a take-over request a button that opens the
* computer; anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
@@ -14,6 +17,21 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (computer_id) return <ComputerRefChip computer_id={computer_id} />;
}
if (href?.startsWith(TakeOverRefHrefPrefix)) {
const [id, mode] = href.slice(TakeOverRefHrefPrefix.length).split("-");
const computer_id = Number(id);
const known = ComputerViewModes.some((m) => m.value == mode);
if (computer_id) {
return (
<TakeOverChip
computer_id={computer_id}
mode={(known ? mode : ComputerViewModes[0].value) as ComputerViewMode}
/>
);
}
}
return (
<Link
href={href}
@@ -7,14 +7,19 @@ import MessageGroupBlock from "./message-group-block";
type Props = {
bot: BotWithLastMessage;
messages: BotMessageWithAttachments[];
/**
* The latest step of the bot's active task, shown while it works.
*/
progress?: string | null;
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
* While the bot works on a reply, a thinking row sits under the last one.
* While the bot works on a reply or a task, a status row sits under the
* last one.
*/
export default function MessageThread({ bot, messages }: Props) {
export default function MessageThread({ bot, messages, progress }: Props) {
const groups = groupMessages({ messages });
return (
@@ -28,7 +33,7 @@ export default function MessageThread({ bot, messages }: Props) {
{groups.map((group) => (
<MessageGroupBlock key={group.key} bot={bot} group={group} />
))}
<BotThinkingRow bot={bot} />
<BotThinkingRow bot={bot} progress={progress} />
</Stack>
</Stack>
);
@@ -0,0 +1,43 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import { Hand } from "lucide-react";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import { BotComputersContext } from "../(data)/bot-computers-context";
type Props = {
computer_id: number;
mode: ComputerViewMode;
};
/**
* The bot asked the user to take over a computer (`takeOverRef`): a
* button that opens it in the viewer, in the mode the bot asked for. A
* computer the bot can't use (any more) is shown by name only.
*/
export default function TakeOverChip({ computer_id, mode }: Props) {
const { computers, openComputer } = useContext(BotComputersContext);
const computer = computers.find((c) => c.id == computer_id);
const name = computer?.name || `computer ${computer_id}`;
const mode_title = ComputerViewModes.find((m) => m.value == mode)?.title || mode;
if (!computer) {
return <Span className="font-bold">{name}</Span>;
}
return (
<Button
type="button"
variant="outlined"
color="gray"
size="smaller"
title={`Open ${name} (${mode_title}) and take over`}
onClick={() => openComputer({ computer_id, mode, take_over: true })}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
>
<Hand size={14} aria-hidden className="shrink-0" />
{`Take over ${name} · ${mode_title}`}
</Button>
);
}