- Ember theme (Obsidian charcoal + #ff7a18 orange), now DEFAULT_THEME_ID; server theme_id whitelist gains 'ember' - Brand banner: transparent Westie mascot + >_BooCode wordmark, big/edge-to-edge (flood-filled to transparency + cropped) - Coder panes are multi-tab: + opens a BooCode tab, split opens a pane (shared ChatTabBar via tabKind + createCoderTab; closeOtherTabs/tab-numbering extended to coder) - Model-attribution: new messages.model column stamped at finalizeCompletion (BooChat/native coder) + dispatcher assistant-row creation (external coder); surfaced via view + wire types + live frame; rendered as a subtle shortened-name chip (shortenModelName) - Composer Web toggle moved into a boxed focus-ringed input; glowing accent dot on tool rows - Claude SDK follow-ups (1M context, follow-up-message fix, collapsed thinking/tool chips) + CLAUDE_SDK_BACKEND=1 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
850 lines
31 KiB
TypeScript
850 lines
31 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History, AlertCircle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
|
import { api } from '@/api/client';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
|
import { shortenModelName } from '@/lib/modelName';
|
|
import { CapHitSentinel } from './CapHitSentinel';
|
|
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuSub,
|
|
ContextMenuSubContent,
|
|
ContextMenuSubTrigger,
|
|
ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
|
|
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
|
|
// Used by the right-click "Send to terminal" submenu so it always reflects
|
|
// currently-open terminal panes without prop drilling from Workspace.
|
|
function useTerminals(): TerminalRegistration[] {
|
|
const [list, setList] = useState(() => terminalsRegistry.list());
|
|
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
|
|
return list;
|
|
}
|
|
|
|
// Wrap a message body with a right-click context menu offering Copy and
|
|
// "Send to terminal → <pane name>". Send is disabled when nothing is
|
|
// selected or no terminal panes are open; clicking a target emits a
|
|
// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id).
|
|
function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
|
const [selection, setSelection] = useState('');
|
|
const terminals = useTerminals();
|
|
const hasSelection = selection.length > 0;
|
|
const canSend = hasSelection && terminals.length > 0;
|
|
|
|
return (
|
|
<ContextMenu
|
|
onOpenChange={(open) => {
|
|
if (open) {
|
|
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
|
|
setSelection(sel);
|
|
}
|
|
}}
|
|
>
|
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
disabled={!hasSelection}
|
|
onSelect={() => {
|
|
void navigator.clipboard.writeText(selection).catch((err) => {
|
|
toast.error(err instanceof Error ? err.message : 'copy failed');
|
|
});
|
|
}}
|
|
>
|
|
Copy
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuSub>
|
|
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
|
|
<ContextMenuSubContent>
|
|
{terminals.length === 0 ? (
|
|
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
|
|
) : (
|
|
terminals.map((t) => (
|
|
<ContextMenuItem
|
|
key={t.paneId}
|
|
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
|
|
>
|
|
{t.label}
|
|
</ContextMenuItem>
|
|
))
|
|
)}
|
|
</ContextMenuSubContent>
|
|
</ContextMenuSub>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
// v1.8.2: human labels for the machine-readable error reasons that ride on
|
|
// failed assistant messages via metadata.kind === 'error'. Kept short so the
|
|
// inline render under "message failed" stays a single muted line.
|
|
const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
|
llm_provider_error: 'LLM provider error',
|
|
tool_execution_failed: 'Tool execution failed',
|
|
summary_after_cap_failed: 'Summary after tool budget hit failed',
|
|
};
|
|
|
|
// v1.14.x-html-artifact-panes: MarkdownBody and its path-linkifier helpers
|
|
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
|
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
|
|
|
export interface MessageActions {
|
|
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
|
onResend?: (chatId: string, content: string) => Promise<void>;
|
|
onFork?: (chatId: string, messageId: string) => Promise<void>;
|
|
onDelete?: (chatId: string, messageId: string) => Promise<void>;
|
|
// write-edit-robustness #4 (BooCoder only): reset the worktree to this
|
|
// message's pre-turn checkpoint and trim the transcript past it. BooChat
|
|
// passes no such callback → the "Restore to here" control never renders.
|
|
onRestoreCheckpoint?: (chatId: string, messageId: string) => Promise<void>;
|
|
}
|
|
|
|
interface Props {
|
|
message: Message;
|
|
sessionChats?: Chat[];
|
|
capHitInfo?: { position: number; isLatest: boolean };
|
|
actions?: MessageActions;
|
|
/** Hide actions that don't apply (fork, delete). */
|
|
hideActions?: ('fork' | 'delete')[];
|
|
/**
|
|
* write-edit-robustness #4: this assistant message has a worktree checkpoint
|
|
* → render "Restore to here" (only when `actions.onRestoreCheckpoint` is also
|
|
* provided). CoderMessageList sets this from the checkpoint set.
|
|
*/
|
|
hasCheckpoint?: boolean;
|
|
/**
|
|
* write-edit-robustness #4: suppress the restore control during an active
|
|
* turn (mirrors composer gating). Defaults to enabled.
|
|
*/
|
|
restoreDisabled?: boolean;
|
|
}
|
|
|
|
function StatsLine({ message }: { message: Message }) {
|
|
const tokens = message.tokens_used;
|
|
if (typeof tokens !== 'number' || tokens <= 0) return null;
|
|
const started = message.started_at ? Date.parse(message.started_at) : NaN;
|
|
const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
|
|
let tps: number | null = null;
|
|
if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
|
|
const seconds = (finished - started) / 1000;
|
|
if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
|
|
}
|
|
const ctxUsed = message.ctx_used;
|
|
const ctxMax = message.ctx_max;
|
|
const ctxPart =
|
|
typeof ctxUsed === 'number'
|
|
? typeof ctxMax === 'number' && ctxMax > 0
|
|
? `${ctxUsed} / ${ctxMax} ctx`
|
|
: `${ctxUsed} ctx`
|
|
: null;
|
|
|
|
const parts: string[] = [`${tokens} tokens`];
|
|
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
|
|
if (ctxPart) parts.push(ctxPart);
|
|
|
|
return (
|
|
<div className="text-[10px] font-mono text-muted-foreground">
|
|
{parts.join(' · ')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionRow({
|
|
message,
|
|
actions,
|
|
hiddenSet,
|
|
hasCheckpoint = false,
|
|
restoreDisabled = false,
|
|
}: {
|
|
message: Message;
|
|
actions?: MessageActions;
|
|
hiddenSet: Set<string>;
|
|
hasCheckpoint?: boolean;
|
|
restoreDisabled?: boolean;
|
|
}) {
|
|
const [justCopied, setJustCopied] = useState(false);
|
|
const [regenerating, setRegenerating] = useState(false);
|
|
const [forking, setForking] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
|
const [restoring, setRestoring] = useState(false);
|
|
|
|
async function copy() {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content);
|
|
setJustCopied(true);
|
|
setTimeout(() => setJustCopied(false), 1200);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'copy failed');
|
|
}
|
|
}
|
|
|
|
async function regenerate() {
|
|
if (regenerating || message.status === 'streaming') return;
|
|
setRegenerating(true);
|
|
try {
|
|
if (actions?.onRegenerate) {
|
|
await actions.onRegenerate(message.chat_id, message.id);
|
|
} else {
|
|
await api.messages.regenerate(message.chat_id, message.id);
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
|
} finally {
|
|
setRegenerating(false);
|
|
}
|
|
}
|
|
|
|
async function resend() {
|
|
if (!canResend) return;
|
|
try {
|
|
if (actions?.onResend) {
|
|
await actions.onResend(message.chat_id, message.content!);
|
|
} else {
|
|
await api.messages.send(message.chat_id, message.content!);
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'resend failed');
|
|
}
|
|
}
|
|
|
|
async function fork() {
|
|
if (forking || message.status !== 'complete') return;
|
|
setForking(true);
|
|
try {
|
|
if (actions?.onFork) {
|
|
await actions.onFork(message.chat_id, message.id);
|
|
} else {
|
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
|
sessionEvents.emit({ type: 'refetch_messages' });
|
|
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
|
} finally {
|
|
setForking(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (deleting) return;
|
|
setDeleting(true);
|
|
try {
|
|
if (actions?.onDelete) {
|
|
await actions.onDelete(message.chat_id, message.id);
|
|
} else {
|
|
await api.messages.remove(message.chat_id, message.id);
|
|
}
|
|
setDeleteOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'delete failed');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
async function confirmRestore() {
|
|
if (restoring || !actions?.onRestoreCheckpoint) return;
|
|
setRestoring(true);
|
|
try {
|
|
await actions.onRestoreCheckpoint(message.chat_id, message.id);
|
|
setRestoreOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'restore failed');
|
|
} finally {
|
|
setRestoring(false);
|
|
}
|
|
}
|
|
|
|
const isAssistant = message.role === 'assistant';
|
|
const isUser = message.role === 'user';
|
|
const canRegen = isAssistant && message.status !== 'streaming';
|
|
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
|
const canFork = message.status === 'complete';
|
|
const canDelete = message.status !== 'streaming';
|
|
// write-edit-robustness #4: show "Restore to here" only for a completed
|
|
// assistant message that has a checkpoint AND when the coder wired the
|
|
// callback. Disabled (but visible) during an active turn.
|
|
const canRestore =
|
|
isAssistant &&
|
|
hasCheckpoint &&
|
|
message.status === 'complete' &&
|
|
!!actions?.onRestoreCheckpoint;
|
|
|
|
return (
|
|
<>
|
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
|
|
<button
|
|
type="button"
|
|
onClick={() => void copy()}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Copy message"
|
|
title="Copy"
|
|
>
|
|
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
</button>
|
|
{canResend && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void resend()}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Resend message"
|
|
title="Resend"
|
|
>
|
|
<RefreshCw className="size-3" />
|
|
</button>
|
|
)}
|
|
{isAssistant && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void regenerate()}
|
|
disabled={!canRegen || regenerating}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Regenerate message"
|
|
title="Regenerate"
|
|
>
|
|
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
)}
|
|
{!hiddenSet.has('fork') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void fork()}
|
|
disabled={!canFork || forking}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Fork from here"
|
|
title="Fork from here"
|
|
>
|
|
<GitFork className="size-3" />
|
|
</button>
|
|
)}
|
|
{!hiddenSet.has('delete') && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteOpen(true)}
|
|
disabled={!canDelete}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Delete message"
|
|
title="Delete message"
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</button>
|
|
)}
|
|
{canRestore && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setRestoreOpen(true)}
|
|
disabled={restoreDisabled || restoring}
|
|
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Restore to here"
|
|
title="Restore worktree to this point"
|
|
>
|
|
<History className="size-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Dialog
|
|
open={deleteOpen}
|
|
onOpenChange={(open) => {
|
|
if (!deleting) setDeleteOpen(open);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
|
|
<DialogDescription>
|
|
This removes the selected message and every later message in this chat. This cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteOpen(false)}
|
|
disabled={deleting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => void confirmDelete()}
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? 'Deleting…' : 'Delete'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Dialog
|
|
open={restoreOpen}
|
|
onOpenChange={(open) => {
|
|
if (!restoring) setRestoreOpen(open);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Restore to this point?</DialogTitle>
|
|
<DialogDescription>
|
|
This resets the worktree to before this turn, removes every later
|
|
message in this chat, and resets the agent's session. This cannot
|
|
be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setRestoreOpen(false)}
|
|
disabled={restoring}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => void confirmRestore()}
|
|
disabled={restoring}
|
|
>
|
|
{restoring ? 'Restoring…' : 'Restore'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [shareOpen, setShareOpen] = useState(false);
|
|
const [rerunning, setRerunning] = useState(false);
|
|
|
|
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
|
|
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
|
|
const summaryText = headerMatch
|
|
? message.content.slice(headerMatch[0].length).trim()
|
|
: message.content;
|
|
|
|
async function handleCopy() {
|
|
try {
|
|
await navigator.clipboard.writeText(summaryText);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1200);
|
|
toast.success('Summary copied to clipboard');
|
|
} catch {
|
|
toast.error('Copy failed');
|
|
}
|
|
}
|
|
|
|
async function handleShareToChat(chat: Chat) {
|
|
try {
|
|
await api.messages.send(chat.id, summaryText);
|
|
toast.success(`Summary sent to ${chat.name ?? 'New chat'}`);
|
|
setShareOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to share');
|
|
}
|
|
}
|
|
|
|
async function handleRerun() {
|
|
if (rerunning) return;
|
|
setRerunning(true);
|
|
try {
|
|
await api.chats.compact(message.chat_id);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Re-run failed');
|
|
} finally {
|
|
setRerunning(false);
|
|
}
|
|
}
|
|
|
|
const otherChats = (sessionChats ?? []).filter(
|
|
(c) => c.id !== message.chat_id && c.status === 'open'
|
|
);
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 text-sm">
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
|
|
>
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
<span className="text-xs font-medium truncate">{headerText}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCopy()}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
|
aria-label="Copy summary"
|
|
title="Copy summary"
|
|
>
|
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShareOpen(!shareOpen)}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
|
aria-label="Send to chat"
|
|
title="Send to chat"
|
|
>
|
|
<Share2 size={12} />
|
|
</button>
|
|
{shareOpen && (
|
|
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[180px] py-1">
|
|
{otherChats.length === 0 ? (
|
|
<div className="px-3 py-1.5 text-xs text-muted-foreground">
|
|
No other chats in this session
|
|
</div>
|
|
) : (
|
|
otherChats.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
type="button"
|
|
onClick={() => void handleShareToChat(c)}
|
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
|
|
>
|
|
{c.name ?? 'New chat'}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRerun()}
|
|
disabled={rerunning}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40"
|
|
aria-label="Re-run compact"
|
|
title="Re-run compact"
|
|
>
|
|
<RotateCw size={12} className={rerunning ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
{expanded && (
|
|
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
|
{summaryText}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// v1.11 anchored rolling summary. Inserted by services/compaction.ts as a
|
|
// role='assistant', summary=true row. Distinct from legacy CompactCard
|
|
// (which renders the kind='compact' system rows produced by v1.10 /compact).
|
|
// Collapsed by default; header shows the timestamp; body renders the
|
|
// summary markdown when expanded. Copy button matches CompactCard's affordance.
|
|
function SummaryCard({ message }: { message: Message }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Use finished_at when available (that's when the summary actually landed);
|
|
// fall back to created_at for any row missing it. Both are ISO strings.
|
|
const ts = message.finished_at ?? message.created_at;
|
|
const headerTs = ts ? new Date(ts).toLocaleString() : '';
|
|
|
|
async function handleCopy() {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1200);
|
|
toast.success('Summary copied to clipboard');
|
|
} catch {
|
|
toast.error('Copy failed');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-primary/30 bg-primary/5 text-sm">
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
|
|
>
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
<span className="text-xs font-medium truncate">
|
|
Compacted summary — {headerTs}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCopy()}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
|
aria-label="Copy summary"
|
|
title="Copy summary"
|
|
>
|
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
|
</button>
|
|
</div>
|
|
{expanded && (
|
|
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
|
|
<MarkdownRenderer content={message.content} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Collapsible "Thinking" block for assistant reasoning. Fed by either
|
|
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
|
|
// (native inference, persisted from message_parts). Starts COLLAPSED to start
|
|
// (a quiet chip) — for native BooChat/BooCode and the external agents (opencode,
|
|
// claude SDK) alike — so the transcript stays tidy; click to expand. The
|
|
// `streaming` pulse still animates while the turn runs.
|
|
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
return (
|
|
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
|
|
>
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
<Brain size={13} />
|
|
<span className="text-xs font-medium">Thinking</span>
|
|
{streaming && (
|
|
<span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
|
|
)}
|
|
</button>
|
|
{expanded && (
|
|
<div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
|
|
{text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// feature #12: mistake-recovery sentinel. Inserted by the backend as a
|
|
// role='system', metadata.kind='mistake_recovery' row when the model hit
|
|
// repeated *different* errors (distinct from doom_loop, which is the same
|
|
// call repeated). Visual treatment mirrors CapHitSentinel / DoomLoopSentinel
|
|
// (amber card + alert icon). Non-escalated → recovery guidance was injected
|
|
// and the turn continues. Escalated → the turn was stopped; if can_continue
|
|
// is set, offer the same Continue affordance as the cap-hit sentinel.
|
|
// Loose `!= null` guards per the CLAUDE.md coder-message note (coder rows pass
|
|
// metadata as undefined, not null).
|
|
function MistakeRecoverySentinel({ message }: { message: Message }) {
|
|
const meta = message.metadata;
|
|
const isMistakeRecovery =
|
|
meta != null && typeof meta === 'object' && meta.kind === 'mistake_recovery';
|
|
const failureKinds = isMistakeRecovery ? meta.failure_kinds : [];
|
|
const escalated = isMistakeRecovery ? meta.escalated : false;
|
|
const canContinue = isMistakeRecovery ? meta.can_continue === true : false;
|
|
|
|
const [continuing, setContinuing] = useState(false);
|
|
|
|
async function handleContinue() {
|
|
if (continuing || !canContinue) return;
|
|
setContinuing(true);
|
|
try {
|
|
await api.chats.continue(message.chat_id, message.id);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'continue failed');
|
|
} finally {
|
|
setContinuing(false);
|
|
}
|
|
}
|
|
|
|
const kindsLabel =
|
|
Array.isArray(failureKinds) && failureKinds.length > 0
|
|
? failureKinds.join(', ')
|
|
: null;
|
|
|
|
return (
|
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
|
|
<div className="px-3 py-2 flex items-start gap-2">
|
|
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
|
{escalated ? 'Repeated errors — turn stopped' : 'Recovering from repeated errors'}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{escalated
|
|
? 'Repeated errors persisted — stopped the turn.'
|
|
: kindsLabel
|
|
? `Hit repeated different errors (${kindsLabel}) — recovery guidance injected, continuing.`
|
|
: 'Hit repeated different errors — recovery guidance injected, continuing.'}
|
|
</div>
|
|
{escalated && canContinue && (
|
|
<div className="pt-1">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void handleContinue()}
|
|
disabled={continuing}
|
|
>
|
|
{continuing ? 'Continuing…' : 'Continue'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MessageBubble({
|
|
message,
|
|
sessionChats,
|
|
capHitInfo,
|
|
actions,
|
|
hideActions,
|
|
hasCheckpoint,
|
|
restoreDisabled,
|
|
}: Props) {
|
|
const hiddenSet = new Set(hideActions ?? []);
|
|
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
|
// branch because summary=true never coexists with kind='compact' (new
|
|
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
|
if (message.summary) {
|
|
return <SummaryCard message={message} />;
|
|
}
|
|
if (message.kind === 'compact') {
|
|
return <CompactCard message={message} sessionChats={sessionChats} />;
|
|
}
|
|
|
|
// v1.8.2: cap-hit sentinels render as a distinct system bubble with a
|
|
// Continue button. MessageList's pre-render pass tags each sentinel with
|
|
// its position; only the latest gets the actionable button.
|
|
if (
|
|
message.role === 'system' &&
|
|
message.metadata?.kind === 'cap_hit' &&
|
|
capHitInfo
|
|
) {
|
|
return (
|
|
<CapHitSentinel
|
|
message={message}
|
|
capHitPosition={capHitInfo.position}
|
|
isLatest={capHitInfo.isLatest}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// v1.11.6: doom-loop sentinel. No Continue affordance — retrying with the
|
|
// same tools would just re-loop. The card explains what tripped and
|
|
// suggests next steps (new message angle / switch agents).
|
|
if (message.role === 'system' && message.metadata?.kind === 'doom_loop') {
|
|
return <DoomLoopSentinel message={message} />;
|
|
}
|
|
|
|
// feature #12: mistake-recovery sentinel. Non-escalated rows narrate that
|
|
// recovery guidance was injected mid-turn; escalated rows report the turn
|
|
// was stopped and (when can_continue) offer the cap-hit-style Continue.
|
|
if (message.role === 'system' && message.metadata?.kind === 'mistake_recovery') {
|
|
return <MistakeRecoverySentinel message={message} />;
|
|
}
|
|
|
|
// v1.8.2: tool messages and assistant tool_calls are now rendered by
|
|
// MessageList via ToolCallLine / ToolCallGroup. Tool-role messages reach
|
|
// this point only if MessageList didn't consume them (shouldn't happen,
|
|
// but guard against it by rendering nothing rather than a stale card).
|
|
if (message.role === 'tool') return null;
|
|
|
|
if (message.role === 'user') {
|
|
return (
|
|
<div className="group flex flex-col items-end gap-1">
|
|
<SendToTerminalMenu>
|
|
<div className="boo-user-bubble max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
|
{message.content}
|
|
</div>
|
|
</SendToTerminalMenu>
|
|
<ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isStreaming = message.status === 'streaming';
|
|
const failed = message.status === 'failed';
|
|
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
|
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
|
const hasContent = message.content.trim().length > 0;
|
|
// model-attribution chip: short label for the model that produced this turn.
|
|
const modelLabel = shortenModelName(message.model);
|
|
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
|
|
// inference). Read whichever is present; loose ?? chain tolerates the coder
|
|
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
|
|
const reasoningText = (
|
|
message.reasoning_text ??
|
|
message.reasoning_parts?.map((p) => p.text ?? '').join('') ??
|
|
''
|
|
).trim();
|
|
const hasReasoning = reasoningText.length > 0;
|
|
// v1.8.2: if metadata stamps an error reason, surface it inline under the
|
|
// generic "message failed" line. Keeps the user's eye where it already is
|
|
// rather than introducing a separate banner.
|
|
const errorMeta =
|
|
message.metadata != null && message.metadata.kind === 'error'
|
|
? message.metadata
|
|
: null;
|
|
|
|
return (
|
|
<div className="group flex flex-col gap-2">
|
|
{hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
|
|
{(hasContent || isStreaming) && (
|
|
<SendToTerminalMenu>
|
|
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
|
{hasContent ? <MarkdownRenderer content={message.content} /> : null}
|
|
{isStreaming && (
|
|
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
|
)}
|
|
</div>
|
|
</SendToTerminalMenu>
|
|
)}
|
|
{failed && (
|
|
<div className="text-xs text-destructive">
|
|
message failed
|
|
{errorMeta && (
|
|
<span className="block text-muted-foreground mt-0.5">
|
|
{ERROR_REASON_LABELS[errorMeta.error_reason]}
|
|
{errorMeta.error_text ? ` — ${errorMeta.error_text}` : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!isStreaming && (modelLabel || null) && (
|
|
<span
|
|
className="inline-flex w-fit items-center rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary/90"
|
|
title={message.model ?? undefined}
|
|
>
|
|
{modelLabel}
|
|
</span>
|
|
)}
|
|
{!isStreaming && <StatsLine message={message} />}
|
|
{!isStreaming && hasContent && (
|
|
<ActionRow
|
|
message={message}
|
|
actions={actions}
|
|
hiddenSet={hiddenSet}
|
|
hasCheckpoint={hasCheckpoint}
|
|
restoreDisabled={restoreDisabled}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|