web(coder UI): ChatInput migration + Thinking render + DiffPanel route fix

Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 03:12:06 +00:00
parent 5352fd9942
commit 990a615b87
18 changed files with 427 additions and 545 deletions

View File

@@ -182,10 +182,14 @@ export interface Message {
// majority of messages. // majority of messages.
metadata: MessageMetadata | null; metadata: MessageMetadata | null;
// v1.13.1-C: reasoning content captured from models that stream reasoning // v1.13.1-C: reasoning content captured from models that stream reasoning
// tokens separately (qwen3.6 etc.). Backend populates from message_parts; // tokens separately (qwen3.6 etc.) and from external agents over ACP
// optional on the wire — frontend doesn't render this yet (reserved for // (agent_thought_chunk). Backend populates from message_parts; rendered by
// a v1.14 UI surface). // MessageBubble as a collapsible "Thinking" block.
reasoning_parts?: Array<{ text: string }> | null; reasoning_parts?: Array<{ text: string }> | null;
// Coder wire shape pre-joins reasoning_parts into a single string
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | null;
// v1.11: anchored rolling compaction fields. Optional on the wire so that // v1.11: anchored rolling compaction fields. Optional on the wire so that
// older API responses (or test fixtures) parse without explicit nulls. // older API responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active // summary — true on the assistant row that holds the active

View File

@@ -9,6 +9,7 @@ interface Props {
export function AgentCommandsHint({ commands }: Props) { export function AgentCommandsHint({ commands }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
if (commands.length === 0) return null; if (commands.length === 0) return null;
@@ -25,10 +26,19 @@ export function AgentCommandsHint({ commands }: Props) {
{open && ( {open && (
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y"> <ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
{commands.map((cmd) => ( {commands.map((cmd) => (
<li key={cmd.name} className="font-mono"> <li
<span className="text-primary/80">/{cmd.name}</span> key={cmd.name}
className="cursor-pointer"
onClick={() => setExpanded((v) => v === cmd.name ? null : cmd.name)}
>
<span className="font-mono text-primary/80">/{cmd.name}</span>
{cmd.description && ( {cmd.description && (
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span> <span className={cn(
'ml-1.5 text-muted-foreground font-sans',
expanded === cmd.name ? '' : 'line-clamp-2',
)}>
{cmd.description}
</span>
)} )}
</li> </li>
))} ))}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react'; import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -125,9 +126,11 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
disabled={disabled} disabled={disabled}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={`${label}: ${currentLabel}`} aria-label={`${label}: ${currentLabel}`}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40" className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
> >
{icon ?? <Cpu className="size-4" />} {icon}
<span className="truncate max-w-[120px]">{currentLabel}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button> </button>
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}> <BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
<div className="px-2">{list}</div> <div className="px-2">{list}</div>
@@ -142,16 +145,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40 max-w-[140px]" className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40"
> >
{icon} {icon}
<span className="truncate">{currentLabel}</span> <span className="truncate max-w-[180px]">{currentLabel}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" /> <ChevronDown className="size-3 opacity-70 shrink-0" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]"> <DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
{options.map((o) => ( {options.map((o) => (
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs"> <DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} /> <Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
{o.label} {o.label}
</DropdownMenuItem> </DropdownMenuItem>
@@ -166,9 +169,10 @@ interface Props {
value: AgentSessionConfig; value: AgentSessionConfig;
onChange: (next: AgentSessionConfig) => void; onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void; onProviderCommandsChange?: (commands: AgentCommand[]) => void;
connected?: boolean;
} }
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) { export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
const allEntries = useProviderSnapshot(projectPath); const allEntries = useProviderSnapshot(projectPath);
const entries = useMemo( const entries = useMemo(
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null, () => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
@@ -255,6 +259,16 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
); );
} }
const providerIcon = (name: string) => {
switch (name) {
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
case 'goose': return <Bird size={13} className="shrink-0" />;
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
default: return <Dog size={13} className="shrink-0" />;
}
};
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label })); const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label })); const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label })); const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
@@ -267,7 +281,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
value={value.provider} value={value.provider}
options={providerOptions} options={providerOptions}
onPick={pickProvider} onPick={pickProvider}
icon={<Cpu className="size-3 shrink-0" />} icon={providerIcon(value.provider)}
/> />
<CompactPicker <CompactPicker
label="Mode" label="Mode"
@@ -283,6 +297,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
disabled={modelOptions.length === 0} disabled={modelOptions.length === 0}
options={modelOptions} options={modelOptions}
onPick={pickModel} onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
/> />
{thinkingOpts.length > 0 && ( {thinkingOpts.length > 0 && (
<CompactPicker <CompactPicker
@@ -293,11 +308,17 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
icon={<Brain className="size-3 shrink-0" />} icon={<Brain className="size-3 shrink-0" />}
/> />
)} )}
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
title={connected ? 'Connected' : 'Disconnected'}
/>
)}
<button <button
type="button" type="button"
onClick={() => void handleRefresh()} onClick={() => void handleRefresh()}
disabled={refreshing} disabled={refreshing}
className="ml-auto inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40" className={cn('inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40', connected === undefined && 'ml-auto')}
aria-label="Refresh provider list" aria-label="Refresh provider list"
title="Refresh providers" title="Refresh providers"
> >

View File

@@ -22,6 +22,7 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover'; import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay'; import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker'; import { AgentPicker } from '@/components/AgentPicker';
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { ContextBar } from '@/components/ContextBar'; import { ContextBar } from '@/components/ContextBar';
import { SlashCommandPicker } from '@/components/SlashCommandPicker'; import { SlashCommandPicker } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
@@ -560,6 +561,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
))} ))}
</div> </div>
)} )}
{skills.length > 0 && (
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} />
)}
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1 {/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
inlines ContextBar in the same row so the bar lives next to the inlines ContextBar in the same row so the bar lives next to the
picker rather than as a separate header above it. The row renders picker rather than as a separate header above it. The row renders

View File

@@ -181,7 +181,7 @@ export function ChatTabBar({
<Plus size={12} /> <Plus size={12} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40"> <DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}> <DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types'; import type { Chat, ErrorReason, Message } from '@/api/types';
import { api, ApiError } from '@/api/client'; import { api, ApiError } from '@/api/client';
@@ -117,12 +117,20 @@ function deriveMarkdownTitle(content: string): string {
return 'Markdown artifact'; return 'Markdown artifact';
} }
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>;
}
interface Props { interface Props {
message: Message; message: Message;
sessionChats?: Chat[]; sessionChats?: Chat[];
// v1.8.2: passed by MessageList's render-item pass for cap-hit sentinels.
// Only the most recent sentinel shows the Continue button.
capHitInfo?: { position: number; isLatest: boolean }; capHitInfo?: { position: number; isLatest: boolean };
actions?: MessageActions;
/** Hide actions that don't apply (fork, delete, open-in-pane). */
hideActions?: ('fork' | 'delete' | 'openInPane')[];
} }
function StatsLine({ message }: { message: Message }) { function StatsLine({ message }: { message: Message }) {
@@ -157,8 +165,12 @@ function StatsLine({ message }: { message: Message }) {
function ActionRow({ function ActionRow({
message, message,
actions,
hiddenSet,
}: { }: {
message: Message; message: Message;
actions?: MessageActions;
hiddenSet: Set<string>;
}) { }) {
const [justCopied, setJustCopied] = useState(false); const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false); const [regenerating, setRegenerating] = useState(false);
@@ -180,7 +192,11 @@ function ActionRow({
if (regenerating || message.status === 'streaming') return; if (regenerating || message.status === 'streaming') return;
setRegenerating(true); setRegenerating(true);
try { try {
await api.messages.regenerate(message.chat_id, message.id); if (actions?.onRegenerate) {
await actions.onRegenerate(message.chat_id, message.id);
} else {
await api.messages.regenerate(message.chat_id, message.id);
}
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed'); toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally { } finally {
@@ -188,12 +204,30 @@ function ActionRow({
} }
} }
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() { async function fork() {
if (forking || message.status !== 'complete') return; if (forking || message.status !== 'complete') return;
setForking(true); setForking(true);
try { try {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id }); if (actions?.onFork) {
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id }); 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_active_pane', chat_id: chat.id });
}
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed'); toast.error(err instanceof Error ? err.message : 'fork failed');
} finally { } finally {
@@ -205,7 +239,11 @@ function ActionRow({
if (deleting) return; if (deleting) return;
setDeleting(true); setDeleting(true);
try { try {
await api.messages.remove(message.chat_id, message.id); if (actions?.onDelete) {
await actions.onDelete(message.chat_id, message.id);
} else {
await api.messages.remove(message.chat_id, message.id);
}
setDeleteOpen(false); setDeleteOpen(false);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed'); toast.error(err instanceof Error ? err.message : 'delete failed');
@@ -215,7 +253,9 @@ function ActionRow({
} }
const isAssistant = message.role === 'assistant'; const isAssistant = message.role === 'assistant';
const isUser = message.role === 'user';
const canRegen = isAssistant && message.status !== 'streaming'; const canRegen = isAssistant && message.status !== 'streaming';
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
const canFork = message.status === 'complete'; const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming'; const canDelete = message.status !== 'streaming';
const [openingPane, setOpeningPane] = useState(false); const [openingPane, setOpeningPane] = useState(false);
@@ -279,7 +319,18 @@ function ActionRow({
> >
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />} {justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button> </button>
{isAssistant && ( {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 && !hiddenSet.has('openInPane') && (
<button <button
type="button" type="button"
onClick={() => void openInPane()} onClick={() => void openInPane()}
@@ -303,26 +354,30 @@ function ActionRow({
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} /> <RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button> </button>
)} )}
<button {!hiddenSet.has('fork') && (
type="button" <button
onClick={() => void fork()} type="button"
disabled={!canFork || forking} onClick={() => void fork()}
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]" disabled={!canFork || forking}
aria-label="Fork from here" 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]"
title="Fork from here" aria-label="Fork from here"
> title="Fork from here"
<GitFork className="size-3" /> >
</button> <GitFork className="size-3" />
<button </button>
type="button" )}
onClick={() => setDeleteOpen(true)} {!hiddenSet.has('delete') && (
disabled={!canDelete} <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]" 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" aria-label="Delete message"
title="Delete message" title="Delete message"
> >
<Trash2 className="size-3" /> <Trash2 className="size-3" />
</button> </button>
)}
</div> </div>
<Dialog <Dialog
open={deleteOpen} open={deleteOpen}
@@ -536,7 +591,39 @@ function SummaryCard({ message }: { message: Message }) {
); );
} }
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) { // 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). Auto-expands while the turn
// is still streaming so the user watches it think (Paseo-style), then stays
// where the user left it once the turn completes — initial state is captured
// once at mount, so we never fight a manual collapse on later re-renders.
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
const [expanded, setExpanded] = useState(() => streaming);
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>
);
}
export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions }: Props) {
const hiddenSet = new Set(hideActions ?? []);
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact' // v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
// branch because summary=true never coexists with kind='compact' (new // branch because summary=true never coexists with kind='compact' (new
// compactions emit role='assistant' rows with kind='message'+summary=true). // compactions emit role='assistant' rows with kind='message'+summary=true).
@@ -585,7 +672,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
{message.content} {message.content}
</div> </div>
</SendToTerminalMenu> </SendToTerminalMenu>
<ActionRow message={message} /> <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
</div> </div>
); );
} }
@@ -595,16 +682,26 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only // v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
// assistant turn doesn't render an empty bubble + dangling ActionRow. // assistant turn doesn't render an empty bubble + dangling ActionRow.
const hasContent = message.content.trim().length > 0; const hasContent = message.content.trim().length > 0;
// 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 // 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 // generic "message failed" line. Keeps the user's eye where it already is
// rather than introducing a separate banner. // rather than introducing a separate banner.
const errorMeta = const errorMeta =
message.metadata !== null && message.metadata.kind === 'error' message.metadata != null && message.metadata.kind === 'error'
? message.metadata ? message.metadata
: null; : null;
return ( return (
<div className="group flex flex-col gap-2"> <div className="group flex flex-col gap-2">
{hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
{(hasContent || isStreaming) && ( {(hasContent || isStreaming) && (
<SendToTerminalMenu> <SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0"> <div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
@@ -627,7 +724,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
</div> </div>
)} )}
{!isStreaming && <StatsLine message={message} />} {!isStreaming && <StatsLine message={message} />}
{!isStreaming && hasContent && <ActionRow message={message} />} {!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
</div> </div>
); );
} }

View File

@@ -273,7 +273,7 @@ export function MobileTabSwitcher({
<MoreHorizontal size={14} /> <MoreHorizontal size={14} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="min-w-44">
{chat && ( {chat && (
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}> <DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
<Edit2 size={14} /> Rename chat <Edit2 size={14} /> Rename chat

View File

@@ -27,7 +27,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
<Plus size={16} /> <Plus size={16} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}> <DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -1,185 +1,27 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useState } from 'react';
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Chat } from '@/api/types';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { formatTokens } from '@/lib/format';
interface Props { interface Props {
sessionId: string;
projectId: string; projectId: string;
chats: Chat[]; sessionId: string;
onOpenChat: (chatId: string) => void; agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void; onSend: (content: string) => void;
/** Create a chat and return its id. Used by slash-command handler. */
createChat: () => Promise<{ id: string }>; createChat: () => Promise<{ id: string }>;
onReopenChat: (chatId: string) => Promise<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onRenameChat: (chatId: string, name: string) => Promise<void>;
onDeleteChat: (chatId: string) => Promise<void>;
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
interface ChatRowProps {
chat: Chat;
onClick: () => void;
dimmed?: boolean;
trailing?: React.ReactNode;
actions?: React.ReactNode;
renamingId: string | null;
renameValue: string;
setRenameValue: (s: string) => void;
onFinishRename: () => void;
onCancelRename: () => void;
onContextStartRename: () => void;
onContextArchive: () => void;
onContextDelete: () => void;
showContextMenu: boolean;
}
function ChatRow({
chat,
onClick,
dimmed,
trailing,
actions,
renamingId,
renameValue,
setRenameValue,
onFinishRename,
onCancelRename,
onContextStartRename,
onContextArchive,
onContextDelete,
showContextMenu,
}: ChatRowProps) {
const meta: string[] = [relTime(chat.updated_at)];
if (chat.message_count !== undefined && chat.message_count > 0) {
meta.push(`${chat.message_count} msg`);
}
const tokens = formatTokens(chat.effective_context_tokens);
if (tokens) meta.push(tokens);
const preview = chat.last_message_preview;
const isRenaming = renamingId === chat.id;
const inner = (
<button
type="button"
onClick={onClick}
className="w-full flex flex-col gap-0.5 px-3 py-2 hover:bg-muted/50 text-left"
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
{isRenaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => onFinishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') onFinishRename();
if (e.key === 'Escape') onCancelRename();
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
{chat.name ?? 'New chat'}
</span>
)}
{trailing && (
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
)}
{actions && (
<div className="flex items-center gap-0.5 shrink-0">{actions}</div>
)}
</div>
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
{meta.join(' · ')}
</div>
{preview && (
<div className="ml-5 text-xs italic text-muted-foreground truncate">
{preview}
</div>
)}
</button>
);
if (!showContextMenu) return inner;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{inner}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onClick}>Open</ContextMenuItem>
<ContextMenuItem onSelect={onContextStartRename}>Rename</ContextMenuItem>
<ContextMenuItem onSelect={onContextArchive}>Archive</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={onContextDelete}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
} }
export function SessionLandingPage({ export function SessionLandingPage({
chats,
onOpenChat,
onSend,
projectId, projectId,
sessionId,
agentId,
onAgentChange,
onSend,
createChat, createChat,
onReopenChat,
onArchiveChat,
onRenameChat,
onDeleteChat,
}: Props) { }: Props) {
const [composerValue, setComposerValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null); const [chatId, setChatId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const openChats = chats
.filter((c) => c.status === 'open')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
const archivedChats = chats
.filter((c) => c.status === 'archived')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
// Create a chat lazily on first send or slash command.
const ensureChat = useCallback(async (): Promise<string> => { const ensureChat = useCallback(async (): Promise<string> => {
if (chatId) return chatId; if (chatId) return chatId;
try { try {
@@ -192,207 +34,46 @@ export function SessionLandingPage({
} }
}, [chatId, createChat]); }, [chatId, createChat]);
async function handleSend() { const handleSend = useCallback(async (content: string) => {
const text = composerValue.trim(); const text = content.trim();
if (!text) return; if (!text) return;
try { try {
const cid = await ensureChat(); await ensureChat();
onSend(text); onSend(text);
setComposerValue('');
} catch { } catch {
// Error already surfaced via toast. // Error already surfaced via toast.
} }
} }, [ensureChat, onSend]);
// v2.3: slash-command dispatch on landing page. Creates a chat first if
// one doesn't exist, then invokes the skill on that chat.
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => { const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try { try {
const cid = await ensureChat(); const cid = await ensureChat();
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null); await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
setComposerValue('');
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`); toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
} }
}, [ensureChat]); }, [ensureChat]);
function startRename(chat: Chat) {
setRenamingId(chat.id);
setRenameValue(chat.name ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRenameChat(renamingId, renameValue.trim());
}
setRenamingId(null);
}
// TODO: Landing page chat counts are a snapshot at mount. New messages in
// visible chats won't update the per-row stats until next mount/navigation.
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6"> <div className="flex-1 flex items-center justify-center px-6">
{openChats.length > 0 && ( <p className="text-sm text-muted-foreground">
<div> Send a message to start.
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3> </p>
<ul className="divide-y rounded-md border">
{openChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => onOpenChat(chat.id)}
renamingId={renamingId}
renameValue={renameValue}
setRenameValue={setRenameValue}
onFinishRename={() => void finishRename()}
onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => setDeleteConfirm(chat)}
showContextMenu
actions={
<>
<Button
variant="ghost"
size="icon-sm"
aria-label="Archive chat"
title="Archive chat"
onClick={(e) => {
e.stopPropagation();
setArchiveConfirm(chat);
}}
>
<Archive size={14} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete chat"
title="Delete chat"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
}}
>
<Trash2 size={14} />
</Button>
</>
}
/>
</li>
))}
</ul>
</div>
)}
{archivedChats.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Archived chats ({archivedChats.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archivedChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => void onReopenChat(chat.id)}
dimmed
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
renamingId={null}
renameValue=""
setRenameValue={() => {}}
onFinishRename={() => {}}
onCancelRename={() => {}}
onContextStartRename={() => {}}
onContextArchive={() => {}}
onContextDelete={() => {}}
showContextMenu={false}
/>
</li>
))}
</ul>
)}
</div>
)}
{openChats.length === 0 && archivedChats.length === 0 && (
<div className="text-sm text-muted-foreground py-8 text-center">
No chats yet. Type below to start a conversation.
</div>
)}
</div> </div>
<ChatInput
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea. disabled={false}
chatId is created lazily on first send/slash. */} projectId={projectId}
<div className="border-t px-4 py-3 shrink-0"> sessionId={sessionId}
<ChatInput agentId={agentId ?? null}
projectId={projectId} onAgentChange={onAgentChange}
onSend={handleSend} onSend={handleSend}
onSlashCommand={handleSlashCommand} onSlashCommand={handleSlashCommand}
chatId={chatId ?? undefined} chatId={chatId ?? undefined}
chatLabel={chatId ? undefined : 'Chat'} chatLabel="Chat"
disabled={false} messages={[]}
/> modelContextLimit={null}
</div> />
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive chat?</DialogTitle>
<DialogDescription>
Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveConfirm) void onArchiveChat(archiveConfirm.id);
setArchiveConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Permanently delete{' '}
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
{' '}and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -37,6 +37,7 @@ interface Props {
project: Project | null; project: Project | null;
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */ /** New BooCode opens a fresh coder session; chat/terminal split in-place. */
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void; onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
} }
export function Workspace({ export function Workspace({
@@ -48,6 +49,7 @@ export function Workspace({
chatsHook, chatsHook,
session, session,
project, project,
onCoderConnectedChange,
onAddPane, onAddPane,
}: Props) { }: Props) {
const { const {
@@ -141,6 +143,7 @@ export function Workspace({
// Per-coder-pane WS connection (status dot lives in the pane header). // Per-coder-pane WS connection (status dot lives in the pane header).
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({}); const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
@@ -212,24 +215,23 @@ export function Workspace({
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/> />
)} )}
{isCoder && ( {isCoder && !isMobile && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0"> <div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
<Code size={12} className="text-muted-foreground" /> <Code size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">BooCode</span> <span className="text-xs text-muted-foreground">BooCode</span>
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7" className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="New pane" aria-label="New pane"
title="New pane"
> >
<Plus size={12} /> <Plus size={12} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40"> <DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}> <DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>
@@ -241,23 +243,12 @@ export function Workspace({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<span
className={cn(
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
)}
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
/>
{panes.length > 1 && ( {panes.length > 1 && (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => { e.stopPropagation(); removePane(idx); }}
e.stopPropagation(); className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
removePane(idx); aria-label="Close pane"
}}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Close BooCode pane"
title="Close BooCode pane"
> >
<X size={12} /> <X size={12} />
</button> </button>
@@ -283,7 +274,7 @@ export function Workspace({
<Plus size={12} /> <Plus size={12} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40"> <DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}> <DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>
@@ -354,9 +345,15 @@ export function Workspace({
chatId={activePaneChatId(pane)} chatId={activePaneChatId(pane)}
chatPending={isPaneChatPending(pane.id)} chatPending={isPaneChatPending(pane.id)}
projectPath={project?.path} projectPath={project?.path}
onConnectedChange={(connected) => onConnectedChange={(connected) => {
setCoderConnected((prev) => setCoderConnected((prev) =>
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected }, prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
);
onCoderConnectedChange?.(pane.id, connected);
}}
onAgentLabelChange={(label) =>
setCoderLabels((prev) =>
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
) )
} }
/> />
@@ -384,19 +381,12 @@ export function Workspace({
/> />
) : ( ) : (
<SessionLandingPage <SessionLandingPage
sessionId={sessionId}
projectId={projectId} projectId={projectId}
chats={chats} sessionId={sessionId}
agentId={agentId}
onAgentChange={onAgentChange}
createChat={() => api.chats.create(sessionId)} createChat={() => api.chats.create(sessionId)}
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onSend={(content) => void handleLandingSend(idx, content)} onSend={(content) => void handleLandingSend(idx, content)}
onReopenChat={async (chatId) => {
await unarchiveChat(chatId);
openChatInPane(idx, chatId);
}}
onArchiveChat={archiveChat}
onRenameChat={renameChat}
onDeleteChat={deleteChat}
/> />
)} )}
</div> </div>

View File

@@ -0,0 +1,21 @@
interface IconProps {
size?: number;
className?: string;
}
export function ClaudeIcon({ size = 14, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</svg>
);
}
export function OpenCodeIcon({ size = 14, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="96 64 288 384" fill="currentColor" className={className}>
<path d="M320 224V352H192V224H320Z" opacity={0.4} />
<path fillRule="evenodd" clipRule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" />
</svg>
);
}

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { MessageBubble, type MessageActions } from '@/components/MessageBubble';
import { ToolCallGroup } from '@/components/ToolCallGroup'; import { ToolCallGroup } from '@/components/ToolCallGroup';
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine'; import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
import { AskUserInputCard } from '@/components/AskUserInputCard'; import { AskUserInputCard } from '@/components/AskUserInputCard';
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools'; import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
import type { Message } from '@/api/types';
export interface CoderMessageWire { export interface CoderMessageWire {
id: string; id: string;
@@ -141,54 +142,16 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] {
return out; return out;
} }
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const hasText = message.content.trim().length > 0;
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
if (isUser) {
return (
<div className="flex flex-col items-end gap-1">
<div className="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>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{hasReasoning && (
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
{message.reasoning_text}
</pre>
</details>
)}
{(hasText || (isStreaming && !hasReasoning)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasText ? <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>
)}
{message.status === 'failed' && (
<div className="text-xs text-destructive">message failed</div>
)}
</div>
);
}
interface Props { interface Props {
messages: CoderTimelineWire[]; messages: CoderTimelineWire[];
chatId?: string; chatId?: string;
footer?: ReactNode; footer?: ReactNode;
actions?: MessageActions;
} }
export function CoderMessageList({ messages, chatId, footer }: Props) { const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
@@ -220,7 +183,14 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4"> <div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => { {renderItems.map((item) => {
if (item.kind === 'message') { if (item.kind === 'message') {
return <CoderTextBubble key={item.message.id} message={item.message} />; return (
<MessageBubble
key={item.message.id}
message={item.message as unknown as Message}
actions={actions}
hideActions={CODER_HIDDEN_ACTIONS}
/>
);
} }
if (item.kind === 'tool_run') { if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input' && chatId) { if (item.run.call.name === 'ask_user_input' && chatId) {

View File

@@ -4,11 +4,10 @@
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502). // WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Send, Check, X, RefreshCw } from 'lucide-react'; import { Code, Check, X, RefreshCw } from 'lucide-react';
import { AgentComposerBar } from '@/components/AgentComposerBar'; import { AgentComposerBar } from '@/components/AgentComposerBar';
import { PermissionCard } from '@/components/PermissionCard'; import { PermissionCard } from '@/components/PermissionCard';
import { AgentCommandsHint } from '@/components/AgentCommandsHint'; import { ChatInput } from '@/components/ChatInput';
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types'; import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills'; import { useSkills } from '@/hooks/useSkills';
@@ -32,6 +31,8 @@ interface CoderMessage {
id: string; id: string;
function: { name: string; arguments: string }; function: { name: string; arguments: string };
}>; }>;
ctx_used?: number | null;
ctx_max?: number | null;
} }
interface CoderToolMessage { interface CoderToolMessage {
@@ -63,6 +64,7 @@ interface Props {
chatPending?: boolean; chatPending?: boolean;
projectPath?: string; projectPath?: string;
onConnectedChange?: (connected: boolean) => void; onConnectedChange?: (connected: boolean) => void;
onAgentLabelChange?: (label: string) => void;
} }
interface WsHandlers { interface WsHandlers {
@@ -91,6 +93,8 @@ type RawCoderMessage = {
| { id: string; name: string; args?: Record<string, unknown> } | { id: string; name: string; args?: Record<string, unknown> }
| { id: string; function: { name: string; arguments: string } } | { id: string; function: { name: string; arguments: string } }
> | null; > | null;
ctx_used?: number | null;
ctx_max?: number | null;
}; };
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null { function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
@@ -126,6 +130,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
status: (raw.status ?? 'complete') as CoderMessage['status'], status: (raw.status ?? 'complete') as CoderMessage['status'],
...(reasoning_text ? { reasoning_text } : {}), ...(reasoning_text ? { reasoning_text } : {}),
...(tool_calls?.length ? { tool_calls } : {}), ...(tool_calls?.length ? { tool_calls } : {}),
ctx_used: raw.ctx_used ?? null,
ctx_max: raw.ctx_max ?? null,
}; };
} }
@@ -228,7 +234,12 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
); );
const next = prev.map((m) => const next = prev.map((m) =>
m.id === frame.message_id && m.role !== 'tool' m.id === frame.message_id && m.role !== 'tool'
? { ...m, status: 'complete' as const } ? {
...m,
status: 'complete' as const,
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
}
: m, : m,
); );
if (completed) { if (completed) {
@@ -343,7 +354,7 @@ function usePendingChanges(sessionId: string) {
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
const approve = useCallback(async (changeId: string) => { const approve = useCallback(async (changeId: string) => {
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, { const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
method: 'POST', method: 'POST',
}); });
if (res.ok) { if (res.ok) {
@@ -352,7 +363,7 @@ function usePendingChanges(sessionId: string) {
}, [sessionId]); }, [sessionId]);
const reject = useCallback(async (changeId: string) => { const reject = useCallback(async (changeId: string) => {
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, { const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
method: 'POST', method: 'POST',
}); });
if (res.ok) { if (res.ok) {
@@ -463,6 +474,7 @@ export function CoderPane({
chatPending = false, chatPending = false,
projectPath, projectPath,
onConnectedChange, onConnectedChange,
onAgentLabelChange,
}: Props) { }: Props) {
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({ const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
provider: 'boocode', provider: 'boocode',
@@ -470,6 +482,12 @@ export function CoderPane({
modeId: null, modeId: null,
thinkingOptionId: null, thinkingOptionId: null,
}); });
useEffect(() => {
const parts = [agentConfig.provider || 'boocode'];
if (agentConfig.model) parts.push(agentConfig.model);
onAgentLabelChange?.(parts.join(' · '));
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null); const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null); const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
const [permissionBusy, setPermissionBusy] = useState(false); const [permissionBusy, setPermissionBusy] = useState(false);
@@ -515,6 +533,8 @@ export function CoderPane({
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [queue, setQueue] = useState<string[]>([]);
const queueProcessing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
// Refresh pending changes when a message_complete arrives // Refresh pending changes when a message_complete arrives
@@ -658,43 +678,87 @@ export function CoderPane({
setMessages, setMessages,
]); ]);
const handleSlashSelect = useCallback((name: string) => {
const next = `/${name} `;
setInput(next);
setSlashState(null);
requestAnimationFrame(() => {
const ta = inputRef.current;
if (ta) {
ta.selectionStart = ta.selectionEnd = next.length;
ta.focus();
}
});
}, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { const sendOneMessage = useCallback(async (text: string) => {
const newValue = e.target.value; if (!chatId) return;
setInput(newValue); setSending(true);
if (isSlashCommandToken(newValue)) { setPermissionPrompt(null);
setSlashState({ query: slashQuery(newValue) }); setLiveTaskCommands([]);
} else {
setSlashState(null); const tempId = `temp-${Date.now()}`;
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
try {
const data = await api.coder.sendMessage(sessionId, {
content: text,
pane_id: paneId,
chat_id: chatId,
provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
model: agentConfig.model || undefined,
mode_id: agentConfig.modeId ?? undefined,
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
});
if (data.user_message_id) {
setMessages((prev) =>
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
);
}
if (data.task_id) {
setActiveTaskId(data.task_id);
} else {
setActiveTaskId(null);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send');
} finally {
setSending(false);
} }
}, []); }, [sessionId, paneId, chatId, agentConfig, setMessages]);
const handleKeyDown = useCallback( // Drain queue when not busy
(e: React.KeyboardEvent) => { useEffect(() => {
if (slashState) return; if (sending || queue.length === 0 || queueProcessing.current) return;
if (e.nativeEvent.isComposing) return; queueProcessing.current = true;
if (e.key === 'Enter' && !e.shiftKey) { const next = queue[0]!;
e.preventDefault(); setQueue((prev) => prev.slice(1));
void handleSend(); sendOneMessage(next).finally(() => { queueProcessing.current = false; });
}, [sending, queue, sendOneMessage]);
const handleChatInputSend = useCallback(async (content: string) => {
const text = content.trim();
if (!text || !chatId) return;
if (sending) {
setQueue((prev) => [...prev, text]);
return;
}
await sendOneMessage(text);
}, [sending, chatId, sendOneMessage]);
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
if (!chatId) return;
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
try {
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setSending(false);
} }
}, }
[handleSend, slashState] }, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
);
return ( return (
<div className="flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
<AgentComposerBar
projectPath={projectPath}
value={agentConfig}
onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange}
connected={connected}
/>
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */} {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
{messages.length === 0 ? ( {messages.length === 0 ? (
@@ -706,6 +770,9 @@ export function CoderPane({
<CoderMessageList <CoderMessageList
messages={messages as CoderTimelineWire[]} messages={messages as CoderTimelineWire[]}
chatId={chatId} chatId={chatId}
actions={{
onResend: async (_chatId, content) => { await sendOneMessage(content); },
}}
footer={ footer={
activeTaskId && !permissionPrompt && sending === false ? ( activeTaskId && !permissionPrompt && sending === false ? (
<p className="text-xs text-muted-foreground animate-pulse">Agent running</p> <p className="text-xs text-muted-foreground animate-pulse">Agent running</p>
@@ -738,44 +805,16 @@ export function CoderPane({
{/* Composer + input */} {/* Composer + input */}
<div className="shrink-0 border-t border-border"> <div className="shrink-0 border-t border-border">
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />} <ChatInput
<AgentComposerBar disabled={sending || !chatId || chatPending}
projectPath={projectPath} projectId={projectPath ?? ''}
value={agentConfig} onSend={handleChatInputSend}
onChange={setAgentConfig} onSlashCommand={handleChatInputSlash}
onProviderCommandsChange={handleProviderCommandsChange} chatId={chatId ?? undefined}
chatLabel="BooCode"
messages={messages as unknown as import('@/api/types').Message[]}
modelContextLimit={null}
/> />
<div className="p-2">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type / for commands…"
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={!input.trim() || sending || !chatId || chatPending}
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="Send message"
>
<Send size={16} />
</button>
</div>
</div>
{slashState && (
<SlashCommandPicker
query={slashState.query}
items={displayedCommands}
inputRef={inputRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -162,6 +162,10 @@ export interface ChatStatusEvent {
reason?: ErrorReason; reason?: ErrorReason;
} }
export interface RefetchMessagesEvent {
type: 'refetch_messages';
}
export type SessionEvent = export type SessionEvent =
| SessionRenamedEvent | SessionRenamedEvent
| ProjectCreatedEvent | ProjectCreatedEvent
@@ -186,7 +190,8 @@ export type SessionEvent =
| ProjectArchivedEvent | ProjectArchivedEvent
| ProjectUnarchivedEvent | ProjectUnarchivedEvent
| ProjectUpdatedEvent | ProjectUpdatedEvent
| ChatStatusEvent; | ChatStatusEvent
| RefetchMessagesEvent;
type Listener = (event: SessionEvent) => void; type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>(); const listeners = new Set<Listener>();

View File

@@ -294,5 +294,21 @@ export function useSessionStream(sessionId: string | undefined) {
}; };
}, [sessionId]); }, [sessionId]);
useEffect(() => {
if (!sessionId) return;
return sessionEvents.subscribe((event) => {
if (event.type === 'refetch_messages') {
void api.messages
.list(sessionId)
.then((messages) => {
setState((s) => applyFrame(s, { type: 'snapshot', messages }));
})
.catch((err: unknown) => {
console.warn('refetch_messages failed', err);
});
}
});
}, [sessionId]);
return state; return state;
} }

View File

@@ -184,6 +184,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_unarchived': case 'chat_unarchived':
case 'chat_deleted': case 'chat_deleted':
case 'chat_status': case 'chat_status':
case 'refetch_messages':
return prev; return prev;
case 'project_archived': { case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id); const next = prev.projects.filter((p) => p.id !== event.project_id);

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useViewport } from './useViewport';
interface SidebarDrawerState { interface SidebarDrawerState {
open: boolean; open: boolean;
@@ -13,13 +14,17 @@ const Ctx = createContext<SidebarDrawerState | null>(null);
export function SidebarDrawerProvider({ children }: { children: ReactNode }) { export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const { isMobile } = useViewport();
// Auto-close on navigation. Effect fires once on mount too (open default
// is false, so no observable effect) and on every pathname change after.
useEffect(() => { useEffect(() => {
setOpen(false); setOpen(false);
}, [location.pathname]); }, [location.pathname]);
// Close drawer on orientation change (landscape→portrait transition).
useEffect(() => {
setOpen(false);
}, [isMobile]);
const toggle = useCallback(() => setOpen((v) => !v), []); const toggle = useCallback(() => setOpen((v) => !v), []);
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>; return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;

View File

@@ -6,7 +6,7 @@ import {
useParams, useParams,
useSearchParams, useSearchParams,
} from 'react-router-dom'; } from 'react-router-dom';
import { ChevronRight, FolderTree, Menu } from 'lucide-react'; import { ChevronRight, FolderTree, Menu, X } from 'lucide-react';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types'; import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
@@ -61,6 +61,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
initializeFirstChatIfEmpty, initializeFirstChatIfEmpty,
validatePanes, validatePanes,
} = panesHook; } = panesHook;
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
const activePane = panes[activePaneIdx];
const activeIsCoder = activePane?.kind === 'coder';
const openChatInActivePane = useCallback( const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId), (chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
@@ -402,6 +405,16 @@ function SessionInner({ sessionId }: { sessionId: string }) {
onAddPane={addPaneAndSwitch} onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES} disabled={panes.length >= MAX_PANES}
/> />
{activeIsCoder && activePane && panes.length > 1 && (
<button
type="button"
onClick={() => removePane(activePaneIdx)}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground shrink-0"
aria-label="Close pane"
>
<X size={16} />
</button>
)}
</div> </div>
</> </>
) : ( ) : (
@@ -495,6 +508,11 @@ function SessionInner({ sessionId }: { sessionId: string }) {
session={session} session={session}
project={project} project={project}
onAddPane={addPaneAndSwitch} onAddPane={addPaneAndSwitch}
onCoderConnectedChange={(paneId, connected) =>
setCoderConnected((prev) =>
prev[paneId] === connected ? prev : { ...prev, [paneId]: connected },
)
}
/> />
)} )}
</div> </div>