1730 lines
64 KiB
TypeScript
1730 lines
64 KiB
TypeScript
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>
|
||
);
|
||
}
|