Files
hive/core/frontend/src/components/ChatPanel.tsx
T

1730 lines
64 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memo, useState, useRef, useEffect, useMemo } from "react";
import { Link } from "react-router-dom";
import {
Send,
Square,
Crown,
Cpu,
Check,
Loader2,
Paperclip,
X,
Zap,
} from "lucide-react";
import WorkerRunBubble from "@/components/WorkerRunBubble";
import type { WorkerRunGroup } from "@/components/WorkerRunBubble";
export interface ImageContent {
type: "image_url";
image_url: { url: string };
}
export interface ContextUsageEntry {
usagePct: number;
messageCount: number;
estimatedTokens: number;
maxTokens: number;
}
import MarkdownContent from "@/components/MarkdownContent";
import QuestionWidget from "@/components/QuestionWidget";
import MultiQuestionWidget from "@/components/MultiQuestionWidget";
import { useQueenProfile } from "@/context/QueenProfileContext";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
import ParallelSubagentBubble, {
type SubagentGroup,
} from "@/components/ParallelSubagentBubble";
import {
formatMessageTime,
formatDayDividerLabel,
workerIdFromStreamId,
} from "@/lib/chat-helpers";
type QueenPhase = "independent" | "incubating" | "working" | "reviewing";
export interface ChatMessage {
id: string;
agent: string;
agentColor: string;
content: string;
timestamp: string;
type?:
| "system"
| "agent"
| "user"
| "tool_status"
| "worker_input_request"
| "run_divider"
| "colony_link"
| "inherited_block"
| "trigger";
role?: "queen" | "worker";
/** Which worker thread this message belongs to (worker agent name) */
thread?: string;
/** Epoch ms when this message was first created — used for ordering queen/worker interleaving */
createdAt?: number;
/** Queen phase active when this message was created */
phase?: QueenPhase;
/** Images attached to a user message */
images?: ImageContent[];
/** Backend node_id that produced this message — used for subagent grouping */
nodeId?: string;
/** Backend execution_id for this message */
executionId?: string;
/** Backend stream_id — the per-worker identity used for grouping
* parallel-spawn workers into their own stacked WorkerRunBubble.
* "queen" for queen messages, "worker" for the single loaded
* worker (run_agent_with_input), or "worker:{uuid}" for each
* parallel worker spawned via run_parallel_workers. */
streamId?: string;
/** True when the message was sent while the queen was still processing */
queued?: boolean;
}
interface ChatPanelProps {
messages: ChatMessage[];
onSend: (message: string, thread: string, images?: ImageContent[]) => void;
isWaiting?: boolean;
/** When true a worker is thinking (not yet streaming) */
isWorkerWaiting?: boolean;
/** When true the queen is busy (typing or streaming) — shows the stop button */
isBusy?: boolean;
activeThread: string;
/** When true, the input is disabled (e.g. during loading) */
disabled?: boolean;
/** When true, only the send button is locked — the textarea stays typable.
* Used during new-session bootstrap so the user can compose a follow-up
* while the queen finishes warming up / streaming her first reply. */
sendLocked?: boolean;
/** When false, the image attach button is hidden (model lacks vision support) */
supportsImages?: boolean;
/** Called when user clicks the stop button to cancel the queen's current turn */
onCancel?: () => void;
/** Called when the user steers a queued message into the current turn —
* the message is sent to the backend immediately so it influences the
* agent after the next tool call completes. */
onSteer?: (messageId: string) => void;
/** Called when the user cancels a still-queued (not-yet-sent) message. */
onCancelQueued?: (messageId: string) => void;
/** Pending questions from ask_user. A single-entry list renders
* QuestionWidget; 2+ entries render MultiQuestionWidget; a single
* entry with no options falls through to the normal text input so
* the user can type a free-form reply. */
pendingQuestions?:
| { id: string; prompt: string; options?: string[] }[]
| null;
/** Called when the user answers pending questions. Keys are question
* ids, values are the chosen/typed answer. Called for both single
* and multi-question flows. */
onQuestionSubmit?: (answers: Record<string, string>) => void;
/** Called when user dismisses the pending question without answering */
onQuestionDismiss?: () => void;
/** Queen operating phase — shown as a tag on queen messages */
queenPhase?: QueenPhase;
/** When false, queen messages omit the phase badge */
showQueenPhaseBadge?: boolean;
/** Context window usage for queen and workers */
contextUsage?: Record<string, ContextUsageEntry>;
/** One-shot composer prefill. Applied to the textarea whenever the value changes. */
initialDraft?: string | null;
/** Queen profile this panel is attached to. When provided, clicking a
* queen avatar/name opens that queen's profile panel directly —
* no fragile name-based lookup against ``queenProfiles``. Nullable
* to tolerate pages that render the panel before the queen is
* resolved (e.g. new-chat bootstrap). */
queenProfileId?: string | null;
/** Queen ID — used to display the queen's avatar photo in messages */
queenId?: string;
/** Called when the user clicks a `colony_link` system message. Receives
* the colony name. The parent should call markColonySpawned + flip
* ``colonySpawned`` to lock the input. The Link still navigates. */
onColonyLinkClick?: (colonyName: string) => void;
/** When true, the composer is replaced with a "compact + new session"
* button — set by the parent after the user opens a spawned colony. */
colonySpawned?: boolean;
/** Name of the colony that locked this DM (shown on the locked button). */
spawnedColonyName?: string | null;
/** Display label for the queen on the locked button (e.g. "Charlotte"). */
queenDisplayName?: string;
/** Called when the user clicks the locked-state button. Should compact
* the current session and navigate to the new one. */
onCompactAndFork?: () => void;
/** When true, disable the compact-and-fork button (request in flight). */
compactingAndForking?: boolean;
/** Called when the user clicks "Start new session" on the locked view.
* Should create a fresh session for the same queen without compacting. */
onStartNewSession?: () => void;
/** When true, disable the start-new-session button (request in flight). */
startingNewSession?: boolean;
/** Cumulative LLM token usage for this session.
* `cached` (cache reads) and `cacheCreated` (cache writes) are subsets of
* `input` — providers count both inside prompt_tokens. Display them
* separately; do not add to a total. */
tokenUsage?: { input: number; output: number; cached?: number; cacheCreated?: number; costUsd?: number };
/** Optional action element rendered on the right side of the "Conversation" header */
headerAction?: React.ReactNode;
}
const queenColor = "hsl(45,95%,58%)";
const workerColor = "hsl(220,60%,55%)";
function queenPhaseLabel(phase?: QueenPhase): string {
return phase ?? "independent";
}
function queenPhaseBadgeClass(phase?: QueenPhase): string {
if (phase === "incubating") {
// Honey-amber tint distinguishes spec incubation from the normal queen modes.
return "bg-amber-500/15 text-amber-500";
}
return "bg-primary/15 text-primary";
}
function getColor(_agent: string, role?: "queen" | "worker"): string {
if (role === "queen") return queenColor;
return workerColor;
}
// Honey-drizzle palette — based on color-hex.com/color-palette/80116
// #8e4200 · #db6f02 · #ff9624 · #ffb825 · #ffd69c + adjacent warm tones
const TOOL_HEX = [
"#db6f02", // rich orange
"#ffb825", // golden yellow
"#ff9624", // bright orange
"#c48820", // warm bronze
"#e89530", // honey
"#d4a040", // goldenrod
"#cc7a10", // caramel
"#e5a820", // sunflower
];
export function toolHex(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++)
hash = (hash * 31 + name.charCodeAt(i)) | 0;
return TOOL_HEX[Math.abs(hash) % TOOL_HEX.length];
}
export function ToolActivityRow({ content }: { content: string }) {
let tools: { name: string; done: boolean }[] = [];
try {
const parsed = JSON.parse(content);
tools = parsed.tools || [];
} catch {
// Legacy plain-text fallback
return (
<div className="flex gap-3 pl-10">
<span className="text-[11px] text-muted-foreground bg-muted/40 px-3 py-1 rounded-full border border-border/40">
{content}
</span>
</div>
);
}
if (tools.length === 0) return null;
// Group by tool name → count done vs running
const grouped = new Map<string, { done: number; running: number }>();
for (const t of tools) {
const entry = grouped.get(t.name) || { done: 0, running: 0 };
if (t.done) entry.done++;
else entry.running++;
grouped.set(t.name, entry);
}
// Build pill list: running first, then done
const runningPills: { name: string; count: number }[] = [];
const donePills: { name: string; count: number }[] = [];
for (const [name, counts] of grouped) {
if (counts.running > 0) runningPills.push({ name, count: counts.running });
if (counts.done > 0) donePills.push({ name, count: counts.done });
}
return (
<div className="flex gap-3 pl-10">
<div className="flex flex-wrap items-center gap-1.5">
{runningPills.map((p) => {
const hex = toolHex(p.name);
return (
<span
key={`run-${p.name}`}
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
style={{
color: hex,
backgroundColor: `${hex}18`,
border: `1px solid ${hex}35`,
}}
>
<Loader2 className="w-2.5 h-2.5 animate-spin" />
{p.name}
{p.count > 1 && (
<span className="text-[10px] font-medium opacity-70">
×{p.count}
</span>
)}
</span>
);
})}
{donePills.map((p) => {
const hex = toolHex(p.name);
return (
<span
key={`done-${p.name}`}
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
style={{
color: hex,
backgroundColor: `${hex}18`,
border: `1px solid ${hex}35`,
}}
>
<Check className="w-2.5 h-2.5" />
{p.name}
{p.count > 1 && (
<span className="text-[10px] opacity-80">×{p.count}</span>
)}
</span>
);
})}
</div>
</div>
);
}
// --- Inline ask_user fallback ---------------------------------------------
// Sometimes the model prints the ask_user payload as regular assistant
// text instead of invoking the tool. We detect that payload here and
// render a QuestionWidget / MultiQuestionWidget inline so the user still
// gets the nice button UI. Submissions are sent back as a regular user
// message via onSend (there is no pending backend state to fulfill, so
// we treat it like the user answering in chat).
type AskUserInlinePayload = {
questions: { id: string; prompt: string; options?: string[] }[];
};
function detectAskUserPayload(content: string): AskUserInlinePayload | null {
if (!content) return null;
let text = content.trim();
if (!text) return null;
// Strip an optional ```json ... ``` / ``` ... ``` code fence
const fence = text.match(/^```(?:json|JSON)?\s*([\s\S]*?)\s*```$/);
if (fence) text = fence[1].trim();
// Strip surrounding double quotes that fully wrap a JSON object
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
const inner = text.slice(1, -1).trim();
if (inner.startsWith("{") && inner.endsWith("}")) text = inner;
}
if (!text.startsWith("{") || !text.endsWith("}")) return null;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
// Normalize to the unified ask_user shape:
// { questions: [{ id, prompt, options? }, ...] }
// Accept either the array form directly, or a legacy single-question
// shape { question, options } that models occasionally still emit —
// it gets wrapped into a one-entry array.
let raw: unknown[] | null = null;
if (Array.isArray(obj.questions)) {
raw = obj.questions as unknown[];
} else if (typeof obj.question === "string" || typeof obj.prompt === "string") {
raw = [obj];
}
if (!raw || raw.length < 1 || raw.length > 8) return null;
const questions: { id: string; prompt: string; options?: string[] }[] = [];
for (let i = 0; i < raw.length; i++) {
const q = raw[i];
if (!q || typeof q !== "object") return null;
const qo = q as Record<string, unknown>;
const prompt =
typeof qo.prompt === "string"
? qo.prompt
: typeof qo.question === "string"
? qo.question
: null;
if (!prompt) return null;
const id = typeof qo.id === "string" && qo.id ? qo.id : `q${i}`;
let options: string[] | undefined;
if (
Array.isArray(qo.options) &&
qo.options.every((o) => typeof o === "string")
) {
options = qo.options as string[];
}
questions.push({ id, prompt, options });
}
// Require either a multi-question batch or a single-with-options
// payload — a single free-form prompt isn't worth a widget.
if (questions.length === 1 && !(questions[0].options && questions[0].options.length >= 2)) {
return null;
}
return { questions };
}
function InlineAskUserBubble({
msg,
payload,
activeThread,
onSend,
queenPhase,
showQueenPhaseBadge = true,
queenProfileId,
queenAvatarUrl,
}: {
msg: ChatMessage;
payload: AskUserInlinePayload;
activeThread: string;
queenAvatarUrl?: string | null;
onSend: (
message: string,
thread: string,
images?: ImageContent[],
) => void;
queenPhase?: QueenPhase;
showQueenPhaseBadge?: boolean;
queenProfileId?: string | null;
}) {
const [state, setState] = useState<"pending" | "submitted" | "dismissed">(
"pending",
);
// Once the user submits an answer via the inline widget, hide the whole
// bubble — their reply appears right after as a normal user message.
if (state === "submitted") return null;
// If the user dismissed without answering, fall back to the regular
// MarkdownContent rendering so they can still see what the model said.
if (state === "dismissed") {
return (
<MessageBubble
msg={msg}
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
/>
);
}
const isQueen = msg.role === "queen";
const color = getColor(msg.agent, msg.role);
const thread = msg.thread || activeThread;
const { openQueenProfile } = useQueenProfile();
const { openColonyWorkers } = useColonyWorkers();
const resolvedQueenProfileId = isQueen ? queenProfileId ?? null : null;
const handleQueenClick = resolvedQueenProfileId
? () => openQueenProfile(resolvedQueenProfileId)
: undefined;
const workerId =
!isQueen && msg.role === "worker"
? workerIdFromStreamId(msg.streamId)
: null;
const handleWorkerClick =
msg.role === "worker"
? () => openColonyWorkers(workerId ?? undefined)
: undefined;
const handleAvatarClick = handleQueenClick ?? handleWorkerClick;
const avatarTitle = handleQueenClick
? `View ${msg.agent}'s profile`
: handleWorkerClick
? "Open worker in colony sidebar"
: undefined;
const handleSubmit = (answers: Record<string, string>) => {
setState("submitted");
if (payload.questions.length === 1) {
const only = payload.questions[0];
onSend(answers[only.id] ?? "", thread);
return;
}
// Format answers as a readable, numbered list for the outgoing message.
const lines = payload.questions.map((q, i) => {
const a = answers[q.id] ?? "";
return `${i + 1}. ${q.prompt}\n ${a}`;
});
onSend(lines.join("\n"), thread);
};
return (
<div className="flex gap-3">
<div
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={isQueen && queenAvatarUrl ? undefined : {
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 6px ${color}10` : undefined,
}}
onClick={handleAvatarClick}
title={avatarTitle}
>
{isQueen ? (
<QueenAvatarIcon url={queenAvatarUrl ?? null} size={9} />
) : (
<Cpu className="w-3.5 h-3.5" style={{ color }} />
)}
</div>
<div
className={`flex-1 min-w-0 ${isQueen ? "max-w-[85%]" : "max-w-[75%]"}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`font-medium ${isQueen ? "text-sm" : "text-xs"}${handleQueenClick ? " cursor-pointer hover:underline" : ""}`}
style={{ color }}
onClick={handleQueenClick}
>
{msg.agent}
</span>
{(!isQueen || showQueenPhaseBadge) && (() => {
const effectivePhase = msg.phase ?? queenPhase;
const badgeClass = isQueen
? queenPhaseBadgeClass(effectivePhase)
: "bg-muted text-muted-foreground";
const label = isQueen ? queenPhaseLabel(effectivePhase) : "Worker";
return (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-md ${badgeClass}`}>
{label}
</span>
);
})()}
</div>
{payload.questions.length >= 2 ? (
<MultiQuestionWidget
inline
questions={payload.questions}
onSubmit={handleSubmit}
onDismiss={() => setState("dismissed")}
/>
) : (
<QuestionWidget
inline
question={payload.questions[0].prompt}
options={payload.questions[0].options ?? []}
onSubmit={(answer) =>
handleSubmit({ [payload.questions[0].id]: answer })
}
onDismiss={() => setState("dismissed")}
/>
)}
</div>
</div>
);
}
function InheritedBlock({
content,
renderMessage,
}: {
content: string;
renderMessage: (msg: ChatMessage) => React.ReactNode;
}) {
// Default to collapsed — the colony's own conversation is what the
// user navigated for; the inherited DM transcript is one click away.
const [open, setOpen] = useState(false);
let parsed: {
parent_session_id?: string | null;
fork_time?: string | null;
summary_preview?: string;
inherited_message_count?: number;
messages?: ChatMessage[];
} = {};
try {
parsed = JSON.parse(content);
} catch {
// fall through to a degraded "Inherited from previous chat" affordance
}
const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
const count =
typeof parsed.inherited_message_count === "number"
? parsed.inherited_message_count
: messages.length;
const preview = (parsed.summary_preview || "").trim();
return (
<div className="my-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 text-[11px] text-muted-foreground bg-muted/30 hover:bg-muted/50 px-3 py-2 rounded-md border border-border/40 transition-colors"
>
<span className="font-medium">
{open ? "▼" : "▶"} Inherited from previous queen DM
</span>
<span className="text-muted-foreground/70">
({count} message{count === 1 ? "" : "s"})
</span>
</button>
{open ? (
<div className="mt-2 pl-3 border-l-2 border-border/40 space-y-2">
{messages.length === 0 ? (
<div className="text-[11px] text-muted-foreground italic px-2 py-1">
{preview || "No messages preserved."}
</div>
) : (
messages.map((m) => (
<div key={m.id} className="opacity-80">
{renderMessage(m)}
</div>
))
)}
</div>
) : preview ? (
<div className="mt-1 text-[11px] text-muted-foreground/80 italic px-3 line-clamp-2">
{preview}
</div>
) : null}
</div>
);
}
function QueenAvatarIcon({ url, size }: { url: string | null; size: number }) {
const [ok, setOk] = useState(!!url);
const dim = size === 9 ? "w-9 h-9" : "w-7 h-7";
if (ok && url) {
return <img src={url} alt="" className={`${dim} rounded-xl object-cover`} onError={() => setOk(false)} />;
}
return <Crown className={size === 9 ? "w-4 h-4" : "w-3.5 h-3.5"} style={{ color: queenColor }} />;
}
const MessageBubble = memo(
function MessageBubble({
msg,
queenPhase,
showQueenPhaseBadge = true,
queenProfileId,
queenAvatarUrl,
onColonyLinkClick,
onSteer,
onCancelQueued,
}: {
msg: ChatMessage;
queenPhase?: QueenPhase;
showQueenPhaseBadge?: boolean;
queenProfileId?: string | null;
queenAvatarUrl?: string | null;
onColonyLinkClick?: (colonyName: string) => void;
onSteer?: (messageId: string) => void;
onCancelQueued?: (messageId: string) => void;
}) {
const isUser = msg.type === "user";
const isQueen = msg.role === "queen";
const color = getColor(msg.agent, msg.role);
// Clicking a queen avatar/name opens the queen profile panel. The
// owning page passes its queenProfileId down — we don't fall back
// to a name-match against ``queenProfiles`` because display names
// aren't unique or stable (colony chat uses static QUEEN_REGISTRY
// labels, queen-dm uses user-editable profile names; matching by
// name silently breaks when the profile is renamed or not listed).
const { openQueenProfile } = useQueenProfile();
const { openColonyWorkers } = useColonyWorkers();
const resolvedQueenProfileId = isQueen ? queenProfileId ?? null : null;
// Worker messages: clicking the avatar opens the Colony Workers
// sidebar, pre-selecting this worker when its uuid is embedded in
// the streamId (parallel fan-out case).
const workerId =
!isQueen && msg.role === "worker"
? workerIdFromStreamId(msg.streamId)
: null;
if (msg.type === "run_divider") {
return (
<div className="flex items-center gap-3 py-2 my-1">
<div className="flex-1 h-px bg-border/60" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
{msg.content}
</span>
<div className="flex-1 h-px bg-border/60" />
</div>
);
}
if (msg.type === "system") {
return (
<div className="flex justify-center py-1">
<span className="text-[11px] text-muted-foreground bg-muted/60 px-3 py-1.5 rounded-full">
{msg.content}
</span>
</div>
);
}
if (msg.type === "trigger") {
// Rendered when a scheduler/webhook trigger fires. Content is a JSON
// payload: { trigger_id, trigger_type, name, task, last_fired_at,
// fire_count }. Shown as a distinctive banner marking the start of
// the turn the queen is about to run in response.
let parsed: {
trigger_id?: string;
trigger_type?: string;
name?: string;
task?: string;
fire_count?: number;
last_fired_at?: number;
} = {};
try {
parsed = JSON.parse(msg.content);
} catch {
// Fall through to plain text
}
const label = parsed.name || parsed.trigger_id || "trigger";
const kind = parsed.trigger_type || "timer";
const task = (parsed.task || "").trim();
const fireCount = parsed.fire_count;
return (
<div className="flex justify-center py-2">
<div className="max-w-[85%] w-full rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2">
<div className="flex items-center gap-2 mb-1">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-amber-500/15 text-amber-400">
<Zap className="w-3 h-3" />
</span>
<span className="text-[11px] font-semibold text-amber-400 uppercase tracking-wider">
{kind === "webhook" ? "Webhook" : "Scheduler"} fired
</span>
<span className="text-[11px] text-foreground font-mono truncate">{label}</span>
{fireCount != null && fireCount > 0 && (
<span className="ml-auto text-[10px] text-muted-foreground">#{fireCount}</span>
)}
</div>
{task && (
<p className="text-[12px] text-muted-foreground leading-snug whitespace-pre-wrap">
{task}
</p>
)}
</div>
</div>
);
}
if (msg.type === "colony_link") {
// Rendered when the queen calls create_colony() and the backend
// emits a COLONY_CREATED event. Gives the user a clickable card
// that navigates to the new colony page. Clicking also locks the
// queen DM (mark-colony-spawned) so the user must compact + fork
// before continuing this conversation.
let parsed: {
colony_name?: string;
is_new?: boolean;
skill_name?: string;
href?: string;
} = {};
try {
parsed = JSON.parse(msg.content);
} catch {
// ignore — fall through to a plain text render
}
const colonyName = parsed.colony_name || "";
const href = parsed.href || (colonyName ? `/colony/${colonyName}` : "");
const skillLabel = parsed.skill_name
? ` · skill: ${parsed.skill_name}`
: "";
const isNewLabel = parsed.is_new === false ? " (updated)" : " (new)";
return (
<div className="flex justify-center py-2">
<Link
to={href}
onClick={() => {
if (colonyName && onColonyLinkClick) {
onColonyLinkClick(colonyName);
}
}}
className="inline-flex items-center gap-2 text-xs font-medium text-primary bg-primary/10 hover:bg-primary/20 px-4 py-2 rounded-full border border-primary/20 transition-colors"
>
<span>🏛</span>
<span>
Colony <strong>{colonyName}</strong>{isNewLabel} ready{skillLabel} open
</span>
</Link>
</div>
);
}
if (msg.type === "inherited_block") {
return (
<InheritedBlock
content={msg.content}
renderMessage={(inner) => (
<MessageBubble
msg={inner}
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
onColonyLinkClick={onColonyLinkClick}
/>
)}
/>
);
}
if (msg.type === "tool_status") {
return <ToolActivityRow content={msg.content} />;
}
if (isUser) {
return (
<div className="flex flex-col items-end gap-1">
<div
className={`max-w-[75%] bg-primary text-primary-foreground text-sm leading-relaxed rounded-2xl rounded-br-md px-4 py-3${msg.queued ? " ring-1 ring-amber-500/50" : ""}`}
>
{msg.images && msg.images.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{msg.images.map((img, i) => (
<img
key={i}
src={img.image_url.url}
alt={`attachment ${i + 1}`}
className="max-h-48 max-w-full rounded-lg object-contain"
/>
))}
</div>
)}
{msg.content && (
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
)}
{(msg.queued || msg.createdAt) && (
<div className="flex justify-end items-center gap-1.5 mt-1 text-[10px] opacity-60">
{msg.queued && (
<span className="inline-flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-400 animate-pulse" />
queued
</span>
)}
{msg.createdAt && <span>{formatMessageTime(msg.createdAt)}</span>}
</div>
)}
</div>
{msg.queued && (onSteer || onCancelQueued) && (
<div className="flex items-center gap-1.5">
{onSteer && (
<button
type="button"
onClick={() => onSteer(msg.id)}
className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 border border-amber-500/30 transition-colors"
title="Send now — influence the current turn after the next tool call"
>
<Zap className="w-3 h-3" />
Steer
</button>
)}
{onCancelQueued && (
<button
type="button"
onClick={() => onCancelQueued(msg.id)}
className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-muted/60 text-muted-foreground hover:bg-muted border border-border transition-colors"
title="Remove this queued message"
>
<X className="w-3 h-3" />
Cancel
</button>
)}
</div>
)}
</div>
);
}
const handleQueenClick = resolvedQueenProfileId
? () => openQueenProfile(resolvedQueenProfileId)
: undefined;
const handleWorkerClick =
msg.role === "worker"
? () => openColonyWorkers(workerId ?? undefined)
: undefined;
const handleAvatarClick = handleQueenClick ?? handleWorkerClick;
const avatarTitle = handleQueenClick
? `View ${msg.agent}'s profile`
: handleWorkerClick
? "Open worker in colony sidebar"
: undefined;
return (
<div className="flex gap-3">
<div
className={`flex-shrink-0 ${isQueen ? "w-9 h-9" : "w-7 h-7"} rounded-xl flex items-center justify-center overflow-hidden${handleAvatarClick ? " cursor-pointer hover:opacity-80 transition-opacity" : ""}`}
style={isQueen && queenAvatarUrl ? undefined : {
backgroundColor: `${color}18`,
border: `1.5px solid ${color}35`,
boxShadow: isQueen ? `0 0 6px ${color}10` : undefined,
}}
onClick={handleAvatarClick}
title={avatarTitle}
>
{isQueen ? (
<QueenAvatarIcon url={queenAvatarUrl ?? null} size={9} />
) : (
<Cpu className="w-3.5 h-3.5" style={{ color }} />
)}
</div>
<div
className={`flex-1 min-w-0 ${isQueen ? "max-w-[85%]" : "max-w-[75%]"}`}
>
<div className="flex items-center gap-2 mb-1">
<span
className={`font-medium ${isQueen ? "text-sm" : "text-xs"}${handleQueenClick ? " cursor-pointer hover:underline" : ""}`}
style={{ color }}
onClick={handleQueenClick}
>
{msg.agent}
</span>
{(!isQueen || showQueenPhaseBadge) && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-md ${
isQueen
? queenPhaseBadgeClass(msg.phase ?? queenPhase)
: "bg-muted text-muted-foreground"
}`}
>
{isQueen ? queenPhaseLabel(msg.phase ?? queenPhase) : "Worker"}
</span>
)}
{msg.createdAt && (
<span className="text-[10px] text-muted-foreground">
{formatMessageTime(msg.createdAt)}
</span>
)}
</div>
<div
className={`text-sm leading-relaxed rounded-2xl rounded-tl-md px-4 py-3 ${
isQueen ? "border border-primary/20 bg-primary/5" : "bg-muted/60"
}`}
>
<MarkdownContent content={msg.content} />
</div>
</div>
</div>
);
},
(prev, next) =>
prev.msg.id === next.msg.id &&
prev.msg.content === next.msg.content &&
prev.msg.phase === next.msg.phase &&
prev.msg.queued === next.msg.queued &&
prev.queenPhase === next.queenPhase &&
prev.showQueenPhaseBadge === next.showQueenPhaseBadge &&
prev.onSteer === next.onSteer &&
prev.onCancelQueued === next.onCancelQueued,
);
export default function ChatPanel({
messages,
onSend,
isWaiting,
isWorkerWaiting,
isBusy,
activeThread,
disabled,
sendLocked,
onCancel,
onSteer,
onCancelQueued,
pendingQuestions,
onQuestionSubmit,
onQuestionDismiss,
queenPhase,
showQueenPhaseBadge = true,
contextUsage,
supportsImages = true,
initialDraft,
queenProfileId,
queenId,
onColonyLinkClick,
colonySpawned,
spawnedColonyName,
queenDisplayName,
onCompactAndFork,
compactingAndForking,
onStartNewSession,
startingNewSession,
tokenUsage,
headerAction,
}: ChatPanelProps) {
const [input, setInput] = useState("");
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
const [readMap, setReadMap] = useState<Record<string, number>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const stickToBottom = useRef(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const lastAppliedDraftRef = useRef<string | null | undefined>(undefined);
const queenAvatarUrl = queenId ? `/api/queen/${queenId}/avatar` : null;
useEffect(() => {
if (!initialDraft || initialDraft === lastAppliedDraftRef.current) return;
lastAppliedDraftRef.current = initialDraft;
setInput(initialDraft);
setTimeout(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
ta.selectionStart = ta.selectionEnd = ta.value.length;
}, 0);
}, [initialDraft]);
const threadMessages = messages.filter((m) => {
if (m.type === "system" && !m.thread) return false;
if (m.thread !== activeThread) return false;
// Hide queen messages whose content is whitespace-only — these are
// tool-use-only turns that have no visible text. During live operation
// tool pills provide context, but on resume the pills are gone so
// the empty bubble is meaningless.
if (m.role === "queen" && !m.type && (!m.content || !m.content.trim()))
return false;
return true;
});
// Group subagent messages into parallel bubbles.
// A subagent message has nodeId containing ":subagent:".
// The run only ends on hard boundaries (user messages, run_dividers)
// so interleaved queen/tool/system messages don't fragment the bubble.
type RenderItem =
| { kind: "message"; msg: ChatMessage }
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] }
| {
kind: "worker_run";
runId: string;
group: WorkerRunGroup;
/** Optional short label shown next to the "Worker" badge.
* Only set when there are multiple parallel workers in the
* same run span (so users can tell them apart). */
label?: string;
}
| { kind: "day_divider"; key: string; createdAt: number };
/** Derive a short label from a parallel-worker stream id.
* `worker:abcdef12-3456-...` → `abcdef12` (first 8 chars of the
* uuid after the `worker:` prefix). Falls back to the first
* message's nodeId when the streamId isn't the expected shape. */
function deriveWorkerLabel(
streamKey: string,
msgs: ChatMessage[],
): string {
if (streamKey.startsWith("worker:")) {
const suffix = streamKey.slice("worker:".length);
// sessions are `session_YYYYMMDD_HHMMSS_<8-hex>` — show the
// trailing hex if present, else first 8 chars of the suffix.
const tail = suffix.match(/_[0-9a-f]{6,}$/i)?.[0]?.slice(1);
return tail ? tail.slice(0, 8) : suffix.slice(0, 8);
}
const nid = msgs.find((m) => m.nodeId)?.nodeId;
return nid || streamKey;
}
const renderItems = useMemo<RenderItem[]>(() => {
const items: RenderItem[] = [];
let i = 0;
while (i < threadMessages.length) {
const msg = threadMessages[i];
const isSubagent = msg.nodeId?.includes(":subagent:");
// Worker run grouping: collect consecutive WORKER-role
// messages (and worker tool_status pills) into a collapsible
// card. Queen tool_status pills (``role === "queen"``) are
// deliberately excluded — the queen's own tool calls are part
// of the queen↔user conversation and should render inline as
// ToolActivityRows, not fold into a "Worker" bubble. Without
// this guard, every queen run_command / read_file / etc. shows
// up under a misleading "Worker" label in the DM.
const isWorkerCandidate =
msg.role === "worker" ||
(msg.type === "tool_status" && msg.role !== "queen");
if (
!isSubagent &&
isWorkerCandidate &&
msg.type !== "user" &&
msg.type !== "run_divider"
) {
const workerMsgs: ChatMessage[] = [];
const firstWorkerMsg = msg;
while (i < threadMessages.length) {
const m = threadMessages[i];
// Hard boundary — stop the worker run group
if (m.type === "user" || m.type === "run_divider") break;
// Queen message with real text — boundary (queen is talking
// to the user, not just emitting a tool)
if (m.role === "queen" && m.content?.trim() && !m.type) break;
// Queen tool_status — NOT a worker activity, don't bucket
// it. Break so the grouping stops and the queen pill
// renders inline.
if (m.type === "tool_status" && m.role === "queen") break;
// Trigger banner — scheduler/webhook fire marking a new
// queen turn. Must not fold into a stale worker run that
// happens to precede it (see also MessageBubble's
// ``type === "trigger"`` render at the amber banner).
if (m.type === "trigger") break;
// Other session-wide banners: colony link, inherited block,
// system notices — none of these belong inside a worker run.
if (
m.type === "colony_link" ||
m.type === "inherited_block" ||
m.type === "system"
)
break;
// Subagent message — different group type, stop here
if (m.nodeId?.includes(":subagent:")) break;
// Worker text messages and worker tool_status belong to the run
if (
m.role === "worker" ||
(m.type === "tool_status" && m.role !== "queen")
) {
workerMsgs.push(m);
i++;
continue;
}
// System message or other — include in the worker run
// group to preserve ordering (they'll render inside the
// expanded view)
workerMsgs.push(m);
i++;
}
if (workerMsgs.length > 0) {
// Parallel fan-out detection: if any message in this span
// is tagged with a parallel-worker streamId (``worker:{uuid}``),
// split the span by streamId and emit one ``worker_run``
// per worker — they render as stacked independent
// ``WorkerRunBubble``s. Un-tagged legacy messages and the
// single-worker ``streamId="worker"`` case fall through to
// the existing single-bubble behavior.
const hasParallel = workerMsgs.some(
(m) => !!m.streamId && /^worker:./.test(m.streamId),
);
if (hasParallel) {
const buckets = new Map<
string,
{ messages: ChatMessage[]; firstAt: number }
>();
// Messages with no streamId (system notes, orphans from
// old restore) attach to the most-recent keyed message's
// bucket so chronology is preserved.
let currentKey: string | null = null;
for (const m of workerMsgs) {
const key =
m.streamId && m.streamId.length > 0
? m.streamId
: currentKey;
if (!key) continue;
if (m.streamId && m.streamId.length > 0) currentKey = m.streamId;
let bucket = buckets.get(key);
if (!bucket) {
bucket = { messages: [], firstAt: m.createdAt ?? 0 };
buckets.set(key, bucket);
}
bucket.messages.push(m);
bucket.firstAt = Math.min(
bucket.firstAt,
m.createdAt ?? Number.POSITIVE_INFINITY,
);
}
const sorted = Array.from(buckets.entries()).sort(
([, a], [, b]) => a.firstAt - b.firstAt,
);
for (const [streamKey, { messages: bucketMsgs }] of sorted) {
items.push({
kind: "worker_run",
runId: `wrun-${firstWorkerMsg.id}-${streamKey}`,
group: { messages: bucketMsgs },
label: deriveWorkerLabel(streamKey, bucketMsgs),
});
}
} else {
items.push({
kind: "worker_run",
runId: `wrun-${firstWorkerMsg.id}`,
group: { messages: workerMsgs },
});
}
}
continue;
}
if (!isSubagent) {
items.push({ kind: "message", msg });
i++;
continue;
}
// Start a subagent run. Collect all subagent messages, allowing
// non-subagent messages in between (they render as normal items
// before the bubble). Only break on hard boundaries.
const subagentMsgs: ChatMessage[] = [];
const interleaved: { idx: number; msg: ChatMessage }[] = [];
const firstId = msg.id;
while (i < threadMessages.length) {
const m = threadMessages[i];
const isSa = m.nodeId?.includes(":subagent:");
if (isSa) {
subagentMsgs.push(m);
i++;
continue;
}
// Hard boundary — stop the run
if (m.type === "user" || m.type === "run_divider") break;
// Worker message from a non-subagent node means the graph has
// moved on to the next stage. Close the bubble even if some
// subagents are still streaming in the background.
if (m.role === "worker" && m.nodeId && !m.nodeId.includes(":subagent:"))
break;
// Soft interruption (queen output, system, tool_status without
// nodeId) — render it normally but keep the subagent run going
interleaved.push({ idx: items.length + interleaved.length, msg: m });
i++;
}
// Emit interleaved messages first (before the bubble)
for (const { msg: im } of interleaved) {
items.push({ kind: "message", msg: im });
}
// Build the single parallel bubble from all collected subagent msgs
if (subagentMsgs.length > 0) {
const byNode = new Map<string, ChatMessage[]>();
for (const m of subagentMsgs) {
const nid = m.nodeId!;
if (!byNode.has(nid)) byNode.set(nid, []);
byNode.get(nid)!.push(m);
}
const groups: SubagentGroup[] = [];
for (const [nodeId, msgs] of byNode) {
groups.push({
nodeId,
messages: msgs,
contextUsage: contextUsage?.[nodeId],
});
}
items.push({ kind: "parallel", groupId: `par-${firstId}`, groups });
}
}
return items;
}, [threadMessages, contextUsage]);
// Inject day-separator dividers between items that cross a calendar-day
// boundary, and one before the very first item. Helps the user see when
// activity resumed after a gap — important since some answers take hours.
const itemsWithDividers = useMemo<RenderItem[]>(() => {
const getTime = (item: RenderItem): number | undefined => {
if (item.kind === "message") return item.msg.createdAt;
if (item.kind === "parallel") {
for (const g of item.groups) {
for (const m of g.messages) {
if (m.createdAt) return m.createdAt;
}
}
}
return undefined;
};
const dayKey = (ts: number) => {
const d = new Date(ts);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
};
const out: RenderItem[] = [];
let lastDay: string | null = null;
for (const item of renderItems) {
const ts = getTime(item);
if (ts) {
const key = dayKey(ts);
if (key !== lastDay) {
out.push({ kind: "day_divider", key: `day-${ts}`, createdAt: ts });
lastDay = key;
}
}
out.push(item);
}
return out;
}, [renderItems]);
// Mark current thread as read
useEffect(() => {
const count = messages.filter((m) => m.thread === activeThread).length;
setReadMap((prev) => ({ ...prev, [activeThread]: count }));
}, [activeThread, messages]);
// Suppress unused var
void readMap;
// Autoscroll: only when user is already near the bottom
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
stickToBottom.current = distFromBottom < 80;
};
useEffect(() => {
if (stickToBottom.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [threadMessages, pendingQuestions, isWaiting, isWorkerWaiting]);
// Always start pinned to bottom when switching threads
useEffect(() => {
stickToBottom.current = true;
}, [activeThread]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() && pendingImages.length === 0) return;
onSend(
input.trim(),
activeThread,
pendingImages.length > 0 ? pendingImages : undefined,
);
setInput("");
setPendingImages([]);
if (textareaRef.current) textareaRef.current.style.height = "auto";
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length === 0) return;
files.forEach((file) => {
const reader = new FileReader();
reader.onload = (ev) => {
const url = ev.target?.result as string;
setPendingImages((prev) => [
...prev,
{ type: "image_url", image_url: { url } },
]);
};
reader.readAsDataURL(file);
});
// Reset so the same file can be re-selected
e.target.value = "";
};
return (
<div className="flex flex-col h-full min-w-0">
{/* Compact sub-header */}
<div className="px-5 pt-4 pb-2 flex items-center gap-2">
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">
Conversation
</p>
{headerAction && <div className="ml-auto">{headerAction}</div>}
</div>
{/* Messages */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-auto px-5 py-4 space-y-3"
>
{itemsWithDividers.map((item) => {
if (item.kind === "day_divider") {
return (
<div
key={item.key}
className="flex items-center gap-3 py-2 my-1"
>
<div className="flex-1 h-px bg-border/60" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
{formatDayDividerLabel(item.createdAt)}
</span>
<div className="flex-1 h-px bg-border/60" />
</div>
);
}
if (item.kind === "parallel") {
return (
<div key={item.groupId}>
<ParallelSubagentBubble
groupId={item.groupId}
groups={item.groups}
/>
</div>
);
}
if (item.kind === "worker_run") {
return (
<div key={item.runId}>
<WorkerRunBubble
runId={item.runId}
group={item.group}
label={item.label}
/>
</div>
);
}
const msg = item.msg;
// Detect misformatted ask_user payloads emitted as plain text and
// substitute the nicer widget-based bubble. Only inspect regular
// agent messages — skip system rows, tool status, dividers, etc.
const askPayload =
(msg.role === "queen" || msg.role === "worker") &&
!msg.type &&
msg.content
? detectAskUserPayload(msg.content)
: null;
if (askPayload) {
return (
<div key={msg.id}>
<InlineAskUserBubble
msg={msg}
payload={askPayload}
activeThread={activeThread}
onSend={onSend}
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
/>
</div>
);
}
return (
<div key={msg.id}>
<MessageBubble
msg={msg}
queenPhase={queenPhase}
showQueenPhaseBadge={showQueenPhaseBadge}
queenProfileId={queenProfileId}
queenAvatarUrl={queenAvatarUrl}
onColonyLinkClick={onColonyLinkClick}
onSteer={onSteer}
onCancelQueued={onCancelQueued}
/>
</div>
);
})}
{/* Show typing indicator while waiting for first queen response
(disabled / sendLocked + empty chat counts as warm-up). */}
{(isWaiting ||
((disabled || sendLocked) && threadMessages.length === 0)) && (
<div className="flex gap-3">
<div
className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center overflow-hidden"
style={queenAvatarUrl ? undefined : {
backgroundColor: `${queenColor}18`,
border: `1.5px solid ${queenColor}35`,
boxShadow: `0 0 6px ${queenColor}10`,
}}
>
<QueenAvatarIcon url={queenAvatarUrl} size={9} />
</div>
<div className="border border-primary/20 bg-primary/5 rounded-2xl rounded-tl-md px-4 py-3">
<div className="flex gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
</div>
</div>
)}
{isWorkerWaiting && !isWaiting && (
<div className="flex gap-3">
<div
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center"
style={{
backgroundColor: `${workerColor}18`,
border: `1.5px solid ${workerColor}35`,
}}
>
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
</div>
<div className="bg-muted/60 rounded-2xl rounded-tl-md px-4 py-3">
<div className="flex gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Context & token usage — compact inline stats */}
{(() => {
const fmt = (tokens: number) => tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
const color = (pct: number) => pct >= 90 ? "text-red-400" : pct >= 70 ? "text-orange-400" : "text-muted-foreground/50";
let queenUsage: ContextUsageEntry | undefined;
if (contextUsage) {
queenUsage = contextUsage["__queen__"];
}
const hasContext = !!queenUsage;
const hasTokens = tokenUsage && (tokenUsage.input > 0 || tokenUsage.output > 0);
if (!hasContext && !hasTokens) return null;
return (
<div className="flex items-center justify-end gap-3 mx-4 px-2 py-0.5 flex-shrink-0 text-[10px] text-muted-foreground/50 tabular-nums">
{queenUsage && (
<span className={color(queenUsage.usagePct)} title={`${queenUsage.messageCount} messages`}>
Context: {fmt(queenUsage.estimatedTokens)}/{fmt(queenUsage.maxTokens)}
</span>
)}
{hasTokens && (() => {
const cached = tokenUsage!.cached ?? 0;
const created = tokenUsage!.cacheCreated ?? 0;
const cost = tokenUsage!.costUsd ?? 0;
// cached/created are subsets of input — never sum; surface separately.
// Cost can be < $0.01; show 4 decimals so small-model sessions aren't "$0.00".
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : "—";
return (
<span className="group relative cursor-help transition-colors hover:text-muted-foreground">
Tokens: {fmt(tokenUsage!.input + tokenUsage!.output)}
<span
role="tooltip"
className="pointer-events-none invisible absolute bottom-full right-0 z-50 mb-2 whitespace-nowrap rounded-md border border-border bg-popover px-3 py-2 text-[11px] text-popover-foreground opacity-0 shadow-lg transition-[opacity,transform] duration-150 translate-y-1 group-hover:visible group-hover:opacity-100 group-hover:translate-y-0"
>
<span className="mb-1.5 block text-muted-foreground">
LLM tokens used this session
</span>
<span className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 tabular-nums">
<span>Input</span>
<span className="text-right">{fmt(tokenUsage!.input)}</span>
<span className="pl-3 text-muted-foreground">cache read</span>
<span className="text-right text-muted-foreground">{fmt(cached)}</span>
<span className="pl-3 text-muted-foreground">cache write</span>
<span className="text-right text-muted-foreground">{fmt(created)}</span>
<span>Output</span>
<span className="text-right">{fmt(tokenUsage!.output)}</span>
<span className="mt-1 border-t border-border/50 pt-1">Cost</span>
<span className="mt-1 border-t border-border/50 pt-1 text-right font-medium">
{costStr}
</span>
</span>
</span>
</span>
);
})()}
</div>
);
})()}
{/* Input area — colony-spawned lock replaces everything; question widget
replaces textarea when a question is pending */}
{colonySpawned ? (
<div className="p-4 border-t border-border/50 bg-muted/20">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-xs text-muted-foreground max-w-md">
This conversation spawned colony{" "}
{spawnedColonyName ? (
<strong className="text-foreground">{spawnedColonyName}</strong>
) : (
"a colony"
)}
. To keep chatting with{" "}
{queenDisplayName || "this queen"}, compact this session and start
a fresh one.
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<button
type="button"
onClick={onCompactAndFork}
disabled={
!onCompactAndFork ||
compactingAndForking ||
startingNewSession
}
className="inline-flex items-center gap-2 text-xs font-medium text-primary-foreground bg-primary hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-2 rounded-full transition-opacity"
>
{compactingAndForking ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Compacting</span>
</>
) : (
<span>
Compact & start new session
{queenDisplayName ? ` with ${queenDisplayName}` : ""}
</span>
)}
</button>
{onStartNewSession && (
<button
type="button"
onClick={onStartNewSession}
disabled={startingNewSession || compactingAndForking}
className="inline-flex items-center gap-2 text-xs font-medium text-foreground bg-muted hover:bg-muted/70 disabled:opacity-50 disabled:cursor-not-allowed px-4 py-2 rounded-full transition-colors"
>
{startingNewSession ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Starting</span>
</>
) : (
<span>
Start new session
{queenDisplayName ? ` with ${queenDisplayName}` : ""}
</span>
)}
</button>
)}
</div>
</div>
</div>
) : pendingQuestions &&
pendingQuestions.length >= 2 &&
onQuestionSubmit ? (
<MultiQuestionWidget
questions={pendingQuestions}
onSubmit={onQuestionSubmit}
onDismiss={onQuestionDismiss}
/>
) : pendingQuestions &&
pendingQuestions.length === 1 &&
pendingQuestions[0].options &&
pendingQuestions[0].options.length >= 2 &&
onQuestionSubmit ? (
<QuestionWidget
question={pendingQuestions[0].prompt}
options={pendingQuestions[0].options}
onSubmit={(answer) =>
onQuestionSubmit({ [pendingQuestions[0].id]: answer })
}
onDismiss={onQuestionDismiss}
/>
) : (
<form onSubmit={handleSubmit} className="p-4">
{/* Image preview strip */}
{pendingImages.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2 px-1">
{pendingImages.map((img, i) => (
<div key={i} className="relative group">
<img
src={img.image_url.url}
alt={`preview ${i + 1}`}
className="h-16 w-16 object-cover rounded-lg border border-border"
/>
<button
type="button"
onClick={() =>
setPendingImages((prev) => prev.filter((_, j) => j !== i))
}
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-2.5 h-2.5" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3 bg-muted/40 rounded-xl px-4 py-2.5 border border-border focus-within:border-primary/40 transition-colors">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
disabled={disabled || !supportsImages}
onClick={() => supportsImages && fileInputRef.current?.click()}
className="flex-shrink-0 p-1 rounded-md text-muted-foreground hover:text-foreground disabled:opacity-30 transition-colors"
title={supportsImages ? "Attach image" : "Image not supported by the current model"}
>
<Paperclip className="w-4 h-4" />
</button>
<textarea
ref={textareaRef}
rows={1}
value={input}
onChange={(e) => {
setInput(e.target.value);
const ta = e.target;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={
disabled
? "Connecting to agent..."
: sendLocked
? "Type ahead — send unlocks once the queen is ready..."
: isBusy
? "Queue a message — or click Steer to inject now..."
: "Message Queen Bee..."
}
disabled={disabled}
className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50 disabled:cursor-not-allowed resize-none overflow-y-auto"
/>
{isBusy && onCancel && (
<button
type="button"
onClick={onCancel}
title="Stop the queen's current turn"
className="p-2 rounded-lg bg-amber-500/15 text-amber-400 border border-amber-500/40 hover:bg-amber-500/25 transition-colors"
>
<Square className="w-4 h-4" />
</button>
)}
<button
type="submit"
disabled={
(!input.trim() && pendingImages.length === 0) ||
disabled ||
sendLocked
}
title={
sendLocked
? "Hold tight — the queen is starting up. Send unlocks once she's ready."
: isBusy
? "Queue message — sent after the current turn, or click Steer on the bubble to send now"
: "Send"
}
className={`p-2 rounded-lg disabled:opacity-30 hover:opacity-90 transition-opacity ${
isBusy
? "bg-amber-500/20 text-amber-600 border border-amber-500/40"
: "bg-primary text-primary-foreground"
}`}
>
{isBusy ? (
<Zap className="w-4 h-4" />
) : (
<Send className="w-4 h-4" />
)}
</button>
</div>
</form>
)}
</div>
);
}