Tidy the bot chat: icon header controls, status in the rail, narrower thread

Drop the working-on panel. Rail rows show the bot's status line first.
The header's computer and model controls are ghost icons opening modals
that explain them, and the bot's text model, provider and computer show
under its name. The thread and composer are held to 760px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 05:02:28 +01:00
co-authored by Claude Opus 5.5
parent 7b580d782e
commit 02ed47dd86
21 changed files with 263 additions and 379 deletions
+8 -5
View File
@@ -14,21 +14,24 @@ type Props = {
bot: BotWithLastMessage;
selected?: boolean;
/**
* Replaces the last-message snippet (the coordinator shows crew status).
* Shown when the bot has no status line, before the last-message
* snippet (the coordinator's crew counts).
*/
subtitle?: string;
};
/**
* 48px rail row. Status is the face: no badges, spinners or state dots.
* 48px rail row. Status is the face and the subtitle (the bot's status
* 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) {
const hue = (bot.hue || null) as BotHue | null;
const snippet =
subtitle ??
(bot.last_message_text ? messagePreview({ text: bot.last_message_text }) : null) ??
bot.role ??
bot.status_line ||
subtitle ||
(bot.last_message_text ? messagePreview({ text: bot.last_message_text }) : "") ||
bot.role ||
"";
const at = bot.last_message_at || bot.last_activity_at;
@@ -1,6 +1,6 @@
import Divider from "@moduletrace/twui/layout/Divider";
import type { BotWithLastMessage } from "@/src/types";
import { crewStatusLine } from "@/src/utils/crew-status";
import { crewCounts, crewStatusLine } from "@/src/utils/crew-status";
import BotRow from "./bot-row";
type Props = {
@@ -10,16 +10,18 @@ type Props = {
};
/**
* Pinned first. Hue-less, and its subtitle is the crew status, not a
* snippet.
* 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) {
const { working, needs_you } = crewCounts({ bots });
return (
<>
<BotRow
bot={bot}
selected={selected}
subtitle={crewStatusLine({ bots })}
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" />
</>
@@ -14,13 +14,13 @@ type Props = {
};
/**
* One computer in the header's computer select, checked when it's the
* One computer in the header's computers modal, checked when it's the
* one the bot is on.
*/
export default function BotComputerOption({ computer, current, onPick }: Props) {
return (
<Row
role="menuitemradio"
role="radio"
aria-checked={current}
tabIndex={0}
onClick={current ? undefined : onPick}
@@ -1,90 +0,0 @@
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>
);
}
@@ -0,0 +1,82 @@
import { useContext, useState } 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";
import { Monitor } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputerOption from "./bot-computer-option";
import HeaderIconButton from "./header-icon-button";
type Props = {
bot: BotWithLastMessage;
};
/**
* 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.
*/
export default function BotComputersModal({ 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;
return (
<Modal
target={
<HeaderIconButton
icon={Monitor}
title={can_switch ? `Switch ${name}'s computer` : `About ${name}'s computer`}
loading={Boolean(switching_id)}
/>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
{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.`}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{can_switch
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
: `This is the only computer ${name} can use right now.`}
</Span>
</Stack>
<Stack
role="radiogroup"
aria-label={`${name}'s computers`}
className="w-full mt-4 py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface"
>
{computers.map((computer, index) => (
<BotComputerOption
key={computer.id}
computer={computer}
current={index == 0}
onPick={async () => {
await switchComputer({ computer_id: computer.id || 0 });
}}
/>
))}
</Stack>
{error ? (
<Span role="alert" className="mt-3 text-[13px] leading-[18px] text-ink-secondary">
{error}
</Span>
) : null}
</Modal>
);
}
@@ -1,29 +1,29 @@
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 Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { ChevronDown, CpuAi } from "lucide-react";
import { CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "./bot-harness-form";
import HeaderIconButton from "./header-icon-button";
type Props = {
bot: BotWithLastMessage;
harnesses?: HarnessOverview[] | null;
onSaved: (harnesses: HarnessOverview[]) => void;
};
/**
* The header's model button: shows the model the bot thinks with and
* opens every model it uses (text, media reading, transcription, speech,
* images) in the Models settings form. On the coordinator that form is the
* defaults, for super admins only; everyone else gets the button read only.
* The header's model icon: opens every model the bot uses (text, media
* reading, transcription, speech, images) in the Models settings form. On
* the coordinator that form is the defaults, for super admins only;
* everyone else gets the icon disabled, its title saying why.
*/
export default function BotModelsModal({ bot, harnesses: initial }: Props) {
export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const [harnesses, setHarnesses] = useState(initial);
const [open, setOpen] = useState(false);
if (!harnesses?.length || !bot.id) return null;
@@ -33,47 +33,27 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
!is_coordinator ||
isPassThroughUser({ user_types: pageProps?.user_types });
const name = bot.name || "This bot";
const current =
harnesses.find((h) => h.harness == "text")?.current?.name ||
"No model set up";
const target = (
<Button
<HeaderIconButton
icon={CpuAi}
title={
can_edit
? `${name}'s models`
: `${name} uses the default models. A super admin can change them here or in Settings, under Models.`
? is_coordinator
? "Change the default models"
: `Change ${name}'s models`
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
}
type="button"
variant="outlined"
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" />
<Span className="truncate">{current}</Span>
{can_edit ? (
<ChevronDown size={14} aria-hidden className="shrink-0" />
) : null}
</Button>
/>
);
if (!can_edit) {
return (
<Stack className="max-w-[280px] min-w-0 shrink">
{target}
</Stack>
);
}
if (!can_edit) return target;
return (
<Modal
target={target}
targetWrapperProps={{
className: "max-w-[280px] min-w-0 shrink",
}}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-3xl bg-paper px-6 pt-6 pb-0"
@@ -84,15 +64,11 @@ export default function BotModelsModal({ bot, harnesses: initial }: Props) {
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{is_coordinator
? `${name} and every bot left on the default use these. Providers are in Settings, under Models.`
: `${name} uses the default models unless you pick its own.`}
? `${name} and every bot left on the default use these. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it. Providers are in Settings, under Models.`
: `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`}
</Span>
</Stack>
<BotHarnessForm
bot={bot}
harnesses={harnesses}
onSaved={setHarnesses}
/>
<BotHarnessForm bot={bot} harnesses={harnesses} onSaved={onSaved} />
</Modal>
);
}
@@ -14,7 +14,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
if (!pending.length) return null;
return (
<Row className="gap-2! px-6 pb-3">
<Row className="w-full max-w-[760px] mx-auto gap-2! px-6 pb-3">
{pending.map((item) => (
<ComposerAttachmentChip
key={item.key}
@@ -69,35 +69,37 @@ export default function Composer({ bot, onSent }: Props) {
}}
>
<ComposerAttachments pending={files.pending} onRemove={files.remove} />
<Row className="h-[88px] shrink-0 gap-4! flex-nowrap items-center px-6 border-0 border-t border-solid border-hairline">
<ComposerAttachButton disabled={files.full} onFiles={files.add} />
<Textarea
name="message"
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
const pasted = [...e.clipboardData.files];
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<Row className="w-full max-w-[760px] mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
<ComposerAttachButton disabled={files.full} onFiles={files.add} />
<Textarea
name="message"
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
const pasted = [...e.clipboardData.files];
if (!pasted.length) return;
if (!pasted.length) return;
e.preventDefault();
files.add({ files: pasted });
}}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
/>
<ComposerSendButton disabled={!can_send} loading={sending || files.uploading} onClick={send} />
</Row>
e.preventDefault();
files.add({ files: pasted });
}}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
}}
/>
<ComposerSendButton disabled={!can_send} loading={sending || files.uploading} onClick={send} />
</Row>
</Stack>
</Stack>
);
}
@@ -1,17 +1,18 @@
import { useContext, useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import { CpuAi, Monitor } from "lucide-react";
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 { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal";
import HeaderMetaItem from "./header-meta-item";
type Props = {
bot: BotWithLastMessage;
bots: BotWithLastMessage[];
/**
* The bot's models (the defaults for the coordinator).
*/
@@ -19,16 +20,17 @@ type Props = {
};
/**
* 64px header. The coordinator's status is the crew sentence. On the
* 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).
* 64px header. Under the name, what the bot runs on: its text model and
* provider, and the computer it's on. On the right, ghost icon buttons
* that open the computers and models modals. Kept here so a save or a
* switch shows under the name at once.
*/
export default function ConversationHeader({ bot, bots, harnesses }: Props) {
const status =
bot.kind === "coordinator"
? crewStatusSentence({ bots })
: bot.status_line || "";
export default function ConversationHeader({ bot, harnesses: initial }: Props) {
const [harnesses, setHarnesses] = useState(initial);
const { computers } = useContext(BotComputersContext);
const model = harnesses?.find((h) => h.harness == "text")?.current;
const computer = computers[0];
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
@@ -38,16 +40,36 @@ export default function ConversationHeader({ bot, bots, harnesses }: Props) {
size={40}
/>
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{bot.name}
</H1>
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate">
{status}
</Span>
<Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
<HeaderMetaItem
icon={CpuAi}
label="Model"
text={
model
? `${model.name} · ${model.provider_name}`
: "No model set up"
}
/>
{computer?.id ? (
<HeaderMetaItem
icon={Monitor}
label="Computer"
text={computer.name || `Computer ${computer.id}`}
/>
) : null}
</Row>
</Stack>
<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 className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
<BotComputersModal bot={bot} />
<BotModelsModal
key={bot.id}
bot={bot}
harnesses={harnesses}
onSaved={setHarnesses}
/>
</Row>
</Row>
);
@@ -0,0 +1,33 @@
import Button from "@moduletrace/twui/layout/Button";
import type { LucideIcon } from "lucide-react";
type Props = {
icon: LucideIcon;
/**
* Says what the button does; it has no visible label.
*/
title: string;
disabled?: boolean;
loading?: boolean;
};
/**
* A ghost icon button on the right of the conversation header. Used as a
* modal's target, so it takes no click handler of its own.
*/
export default function HeaderIconButton({ icon: Icon, title, disabled, loading }: Props) {
return (
<Button
title={title}
aria-label={title}
type="button"
variant="ghost"
color="text"
disabled={disabled}
loading={loading}
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Icon size={18} aria-hidden />
</Button>
);
}
@@ -0,0 +1,28 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type { LucideIcon } from "lucide-react";
type Props = {
icon: LucideIcon;
/**
* What the text is, for the tooltip and screen readers.
*/
label: string;
text: string;
};
/**
* One fact under the bot's name in the conversation header: an icon and
* a line of muted text.
*/
export default function HeaderMetaItem({ icon: Icon, label, text }: Props) {
return (
<Row
title={`${label}: ${text}`}
className="min-w-0 shrink gap-1.5! flex-nowrap text-[13px] leading-[18px] text-ink-secondary"
>
<Icon size={13} aria-label={label} className="shrink-0" />
<Span className="truncate">{text}</Span>
</Row>
);
}
@@ -23,7 +23,7 @@ export default function MessageThread({ bot, messages }: Props) {
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full gap-4! items-stretch px-6 pt-6 pb-6"
className="w-full max-w-[760px] mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
>
{groups.map((group) => (
<MessageGroupBlock key={group.key} bot={bot} group={group} />
@@ -1,19 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
/**
* Opens the machine the bot is working on. Disabled until the task has one.
*/
export default function OpenComputerButton({ url }: { url?: string | null }) {
return (
<Button
title="Open computer"
color="gray"
href={url || undefined}
target="_blank"
disabled={!url}
className="w-full h-9 rounded-default text-[13px] font-bold 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"
>
Open computer
</Button>
);
}
@@ -1,28 +0,0 @@
import Span from "@moduletrace/twui/layout/Span";
import { Check } from "lucide-react";
/**
* 14px step mark. Pending has no mark; its label is muted.
*/
export default function TaskStepMark({ state }: { state: string }) {
if (state === "done") {
return (
<Check
size={14}
strokeWidth={1.6 * (24 / 14)}
className="shrink-0 text-ink"
aria-label="Done"
/>
);
}
if (state === "active") {
return (
<Span className="size-3.5 shrink-0 flex items-center justify-center" aria-label="In progress" role="img">
<Span className="size-1.5 rounded-full bg-ink" />
</Span>
);
}
return <Span className="size-3.5 shrink-0" aria-hidden />;
}
@@ -1,34 +0,0 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import TaskStepMark from "./task-step-mark";
type Props = {
label: string;
state: string;
};
/**
* 28px pitch. Done is a tick, active a dot with a bold label.
*/
export default function TaskStepRow({ label, state }: Props) {
const is_active = state === "active";
return (
<Row
className="h-7 gap-2.5! flex-nowrap items-center"
aria-current={is_active ? "step" : undefined}
>
<TaskStepMark state={state} />
<Span
className={twMerge(
"text-[13px] leading-5 truncate",
is_active ? "font-bold text-ink" : "text-ink-secondary",
state === "pending" && "text-ink-disabled",
)}
>
{label}
</Span>
</Row>
);
}
@@ -12,8 +12,9 @@ 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
* The conversation column: header, bottom-anchored thread, composer, the
* thread and composer held to a readable width.
* The bot's computers are shared with the header's modal and the computer
* buttons in the thread, so a switch from either shows in both.
*/
export default function Conversation() {
@@ -56,7 +57,6 @@ export default function Conversation() {
<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} />
@@ -1,83 +0,0 @@
import { useContext, useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
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 { 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. 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);
const bot_id = pageProps?.bot?.id;
const [task, setTask] = useState(pageProps?.bot_task);
useServerEvent({
event: "server:bot-updated",
onData: (data: BotUpdatedData) => {
if (data.id == bot_id) setTask(data.task);
},
});
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"
aria-label="Working on"
>
<Span className="pt-[34px] text-xs font-bold leading-4 tracking-[0.06em] uppercase text-ink-secondary">
Working on
</Span>
{task ? (
<>
<H2 className="mt-2.5 text-sm! leading-5! tracking-normal! font-bold text-ink">
{task.title}
</H2>
<Stack className="mt-4 gap-0! items-stretch">
{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>
</>
) : (
<Span className="mt-2.5 text-[13px] leading-5 text-ink-secondary">
Nothing in progress.
</Span>
)}
<Stack className="mt-auto h-[88px] shrink-0 gap-0! items-stretch justify-center">
{task ? <OpenComputerButton url={task.computer_url} /> : null}
</Stack>
</Stack>
);
}
@@ -1,7 +1,6 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
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";
@@ -41,9 +40,8 @@ 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, bot_computers] = await Promise.all([
const [bot_messages, 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 })
@@ -56,7 +54,6 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bots,
bot,
bot_messages,
bot_task,
bot_harness_overview,
bot_computers,
},
+1 -7
View File
@@ -2,15 +2,9 @@ import type { PagePropsType } from "@/src/types";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import Conversation from "./(sections)/conversation";
import WorkingOnPanel from "./(sections)/working-on-panel";
export default function BotConversationPage() {
return (
<>
<Conversation />
<WorkingOnPanel />
</>
);
return <Conversation />;
}
export const meta: BunextPageModuleMetaFn = async (params) => {
-1
View File
@@ -273,7 +273,6 @@ export type PagePropsType = {
bots?: BotWithLastMessage[] | null;
bot?: BotWithLastMessage | null;
bot_messages?: BotMessageWithAttachments[] | null;
bot_task?: BotTaskWithSteps | null;
/**
* The open bot's computers, the one it's on first.
*/