From 990a615b8744f1d4c17b41024d5a29243e910f04 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 29 May 2026 03:12:06 +0000 Subject: [PATCH] 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) --- apps/web/src/api/types.ts | 10 +- apps/web/src/components/AgentCommandsHint.tsx | 16 +- apps/web/src/components/AgentComposerBar.tsx | 39 +- apps/web/src/components/ChatInput.tsx | 4 + apps/web/src/components/ChatTabBar.tsx | 2 +- apps/web/src/components/MessageBubble.tsx | 149 +++++-- apps/web/src/components/MobileTabSwitcher.tsx | 2 +- apps/web/src/components/NewPaneMenu.tsx | 2 +- .../web/src/components/SessionLandingPage.tsx | 377 ++---------------- apps/web/src/components/Workspace.tsx | 54 +-- .../src/components/icons/ProviderIcons.tsx | 21 + .../src/components/panes/CoderMessageList.tsx | 58 +-- apps/web/src/components/panes/CoderPane.tsx | 185 +++++---- apps/web/src/hooks/sessionEvents.ts | 7 +- apps/web/src/hooks/useSessionStream.ts | 16 + apps/web/src/hooks/useSidebar.ts | 1 + apps/web/src/hooks/useSidebarDrawer.tsx | 9 +- apps/web/src/pages/Session.tsx | 20 +- 18 files changed, 427 insertions(+), 545 deletions(-) create mode 100644 apps/web/src/components/icons/ProviderIcons.tsx diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index cc31319..88d7a67 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -182,10 +182,14 @@ export interface Message { // majority of messages. metadata: MessageMetadata | null; // v1.13.1-C: reasoning content captured from models that stream reasoning - // tokens separately (qwen3.6 etc.). Backend populates from message_parts; - // optional on the wire — frontend doesn't render this yet (reserved for - // a v1.14 UI surface). + // tokens separately (qwen3.6 etc.) and from external agents over ACP + // (agent_thought_chunk). Backend populates from message_parts; rendered by + // MessageBubble as a collapsible "Thinking" block. 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 // older API responses (or test fixtures) parse without explicit nulls. // summary — true on the assistant row that holds the active diff --git a/apps/web/src/components/AgentCommandsHint.tsx b/apps/web/src/components/AgentCommandsHint.tsx index 64fc741..04d9da7 100644 --- a/apps/web/src/components/AgentCommandsHint.tsx +++ b/apps/web/src/components/AgentCommandsHint.tsx @@ -9,6 +9,7 @@ interface Props { export function AgentCommandsHint({ commands }: Props) { const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(null); if (commands.length === 0) return null; @@ -25,10 +26,19 @@ export function AgentCommandsHint({ commands }: Props) { {open && (
    {commands.map((cmd) => ( -
  • - /{cmd.name} +
  • setExpanded((v) => v === cmd.name ? null : cmd.name)} + > + /{cmd.name} {cmd.description && ( - {cmd.description} + + {cmd.description} + )}
  • ))} diff --git a/apps/web/src/components/AgentComposerBar.tsx b/apps/web/src/components/AgentComposerBar.tsx index 398af5a..6672fca 100644 --- a/apps/web/src/components/AgentComposerBar.tsx +++ b/apps/web/src/components/AgentComposerBar.tsx @@ -1,5 +1,6 @@ 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 type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; @@ -125,9 +126,11 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker disabled={disabled} onClick={() => setOpen(true)} 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 ?? } + {icon} + {currentLabel} + setOpen(false)} title={label}>
    {list}
    @@ -142,16 +145,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker {options.map((o) => ( - onPick(o.id)} className="font-mono text-xs"> + onPick(o.id)} className="text-xs"> {o.label} @@ -166,9 +169,10 @@ interface Props { value: AgentSessionConfig; onChange: (next: AgentSessionConfig) => 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 entries = useMemo( () => 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 ; + case 'opencode': return ; + case 'goose': return ; + case 'qwen': return ; + default: return ; + } + }; + const providerOptions = entries.map((e) => ({ id: e.name, label: e.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 })); @@ -267,7 +281,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma value={value.provider} options={providerOptions} onPick={pickProvider} - icon={} + icon={providerIcon(value.provider)} /> } /> {thinkingOpts.length > 0 && ( } /> )} + {connected !== undefined && ( + + )} - + onAddPane('chat')}> New BooChat diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index cb2893f..00f4a9c 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } 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 type { Chat, ErrorReason, Message } from '@/api/types'; import { api, ApiError } from '@/api/client'; @@ -117,12 +117,20 @@ function deriveMarkdownTitle(content: string): string { return 'Markdown artifact'; } +export interface MessageActions { + onRegenerate?: (chatId: string, messageId: string) => Promise; + onResend?: (chatId: string, content: string) => Promise; + onFork?: (chatId: string, messageId: string) => Promise; + onDelete?: (chatId: string, messageId: string) => Promise; +} + interface Props { message: Message; 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 }; + actions?: MessageActions; + /** Hide actions that don't apply (fork, delete, open-in-pane). */ + hideActions?: ('fork' | 'delete' | 'openInPane')[]; } function StatsLine({ message }: { message: Message }) { @@ -157,8 +165,12 @@ function StatsLine({ message }: { message: Message }) { function ActionRow({ message, + actions, + hiddenSet, }: { message: Message; + actions?: MessageActions; + hiddenSet: Set; }) { const [justCopied, setJustCopied] = useState(false); const [regenerating, setRegenerating] = useState(false); @@ -180,7 +192,11 @@ function ActionRow({ if (regenerating || message.status === 'streaming') return; setRegenerating(true); 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) { toast.error(err instanceof Error ? err.message : 'regenerate failed'); } 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() { if (forking || message.status !== 'complete') return; setForking(true); try { - const chat = await api.chats.fork(message.chat_id, { messageId: message.id }); - sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id }); + 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_active_pane', chat_id: chat.id }); + } } catch (err) { toast.error(err instanceof Error ? err.message : 'fork failed'); } finally { @@ -205,7 +239,11 @@ function ActionRow({ if (deleting) return; setDeleting(true); 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); } catch (err) { toast.error(err instanceof Error ? err.message : 'delete failed'); @@ -215,7 +253,9 @@ function ActionRow({ } 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'; const [openingPane, setOpeningPane] = useState(false); @@ -279,7 +319,18 @@ function ActionRow({ > {justCopied ? : } - {isAssistant && ( + {canResend && ( + + )} + {isAssistant && !hiddenSet.has('openInPane') && ( )} - - + )} + {!hiddenSet.has('delete') && ( + + )} streaming); + return ( +
    + + {expanded && ( +
    + {text} +
    + )} +
    + ); +} + +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' // branch because summary=true never coexists with kind='compact' (new // compactions emit role='assistant' rows with kind='message'+summary=true). @@ -585,7 +672,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) { {message.content} - + ); } @@ -595,16 +682,26 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) { // 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; + // 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 && message.metadata.kind === 'error' ? message.metadata : null; return (
    + {hasReasoning && } {(hasContent || isStreaming) && (
    @@ -627,7 +724,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
    )} {!isStreaming && } - {!isStreaming && hasContent && } + {!isStreaming && hasContent && }
    ); } diff --git a/apps/web/src/components/MobileTabSwitcher.tsx b/apps/web/src/components/MobileTabSwitcher.tsx index 4339c12..e797744 100644 --- a/apps/web/src/components/MobileTabSwitcher.tsx +++ b/apps/web/src/components/MobileTabSwitcher.tsx @@ -273,7 +273,7 @@ export function MobileTabSwitcher({ - + {chat && ( startRename(chat.id, chat.name)}> Rename chat diff --git a/apps/web/src/components/NewPaneMenu.tsx b/apps/web/src/components/NewPaneMenu.tsx index cc9bffa..5b9c5d7 100644 --- a/apps/web/src/components/NewPaneMenu.tsx +++ b/apps/web/src/components/NewPaneMenu.tsx @@ -27,7 +27,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) { - + onAddPane('chat')}> New BooChat diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index 67559db..4601271 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -1,185 +1,27 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react'; +import { useCallback, useState } from 'react'; import { toast } from 'sonner'; -import type { Chat } from '@/api/types'; import { api } from '@/api/client'; -import { Button } from '@/components/ui/button'; 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 { - sessionId: string; projectId: string; - chats: Chat[]; - onOpenChat: (chatId: string) => void; + sessionId: string; + agentId?: string | null; + onAgentChange?: (agentId: string | null) => void | Promise; onSend: (content: string) => void; - /** Create a chat and return its id. Used by slash-command handler. */ createChat: () => Promise<{ id: string }>; - onReopenChat: (chatId: string) => Promise; - onArchiveChat: (chatId: string) => Promise; - onRenameChat: (chatId: string, name: string) => Promise; - onDeleteChat: (chatId: string) => Promise; -} - -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 = ( - - ); - - if (!showContextMenu) return inner; - - return ( - - {inner} - - Open - Rename - Archive - - - Delete - - - - ); } export function SessionLandingPage({ - chats, - onOpenChat, - onSend, projectId, + sessionId, + agentId, + onAgentChange, + onSend, createChat, - onReopenChat, - onArchiveChat, - onRenameChat, - onDeleteChat, }: Props) { - const [composerValue, setComposerValue] = useState(''); const [chatId, setChatId] = useState(null); - const [showArchived, setShowArchived] = useState(false); - const [renamingId, setRenamingId] = useState(null); - const [renameValue, setRenameValue] = useState(''); - const [archiveConfirm, setArchiveConfirm] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState(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 => { if (chatId) return chatId; try { @@ -192,207 +34,46 @@ export function SessionLandingPage({ } }, [chatId, createChat]); - async function handleSend() { - const text = composerValue.trim(); + const handleSend = useCallback(async (content: string) => { + const text = content.trim(); if (!text) return; try { - const cid = await ensureChat(); + await ensureChat(); onSend(text); - setComposerValue(''); } catch { // 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) => { try { const cid = await ensureChat(); await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null); - setComposerValue(''); } catch (err) { toast.error(err instanceof Error ? err.message : `/${skillName} failed`); } }, [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 (
    -
    - {openChats.length > 0 && ( -
    -

    Open chats

    -
      - {openChats.map((chat) => ( -
    • - 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={ - <> - - - - } - /> -
    • - ))} -
    -
    - )} - - {archivedChats.length > 0 && ( -
    - - {showArchived && ( -
      - {archivedChats.map((chat) => ( -
    • - void onReopenChat(chat.id)} - dimmed - trailing={<>Restore} - renamingId={null} - renameValue="" - setRenameValue={() => {}} - onFinishRename={() => {}} - onCancelRename={() => {}} - onContextStartRename={() => {}} - onContextArchive={() => {}} - onContextDelete={() => {}} - showContextMenu={false} - /> -
    • - ))} -
    - )} -
    - )} - - {openChats.length === 0 && archivedChats.length === 0 && ( -
    - No chats yet. Type below to start a conversation. -
    - )} +
    +

    + Send a message to start. +

    - - {/* v2.3: ChatInput with slash-command support replaces the bare Textarea. - chatId is created lazily on first send/slash. */} -
    - -
    - - { if (!open) setArchiveConfirm(null); }}> - - - Archive chat? - - Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time. - - -
    - - -
    -
    -
    - - { if (!open) setDeleteConfirm(null); }}> - - - Delete chat? - - Permanently delete{' '} - {deleteConfirm?.name || '(unnamed)'} - {' '}and all its messages. This cannot be undone. - - -
    - - -
    -
    -
    +
    ); } diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 990f369..f1bf64f 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -37,6 +37,7 @@ interface Props { project: Project | null; /** New BooCode opens a fresh coder session; chat/terminal split in-place. */ onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void; + onCoderConnectedChange?: (paneId: string, connected: boolean) => void; } export function Workspace({ @@ -48,6 +49,7 @@ export function Workspace({ chatsHook, session, project, + onCoderConnectedChange, onAddPane, }: Props) { const { @@ -141,6 +143,7 @@ export function Workspace({ // Per-coder-pane WS connection (status dot lives in the pane header). const [coderConnected, setCoderConnected] = useState>({}); + const [coderLabels, setCoderLabels] = useState>({}); return (
    @@ -212,24 +215,23 @@ export function Workspace({ onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} /> )} - {isCoder && ( -
    + {isCoder && !isMobile && ( +
    BooCode -
    +
    - + onAddPane('chat')}> New BooChat @@ -241,23 +243,12 @@ export function Workspace({ - {panes.length > 1 && ( @@ -283,7 +274,7 @@ export function Workspace({ - + onAddPane('chat')}> New BooChat @@ -354,9 +345,15 @@ export function Workspace({ chatId={activePaneChatId(pane)} chatPending={isPaneChatPending(pane.id)} projectPath={project?.path} - onConnectedChange={(connected) => + onConnectedChange={(connected) => { setCoderConnected((prev) => 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({ /> ) : ( api.chats.create(sessionId)} - onOpenChat={(chatId) => openChatInPane(idx, chatId)} onSend={(content) => void handleLandingSend(idx, content)} - onReopenChat={async (chatId) => { - await unarchiveChat(chatId); - openChatInPane(idx, chatId); - }} - onArchiveChat={archiveChat} - onRenameChat={renameChat} - onDeleteChat={deleteChat} /> )}
    diff --git a/apps/web/src/components/icons/ProviderIcons.tsx b/apps/web/src/components/icons/ProviderIcons.tsx new file mode 100644 index 0000000..a4b77fa --- /dev/null +++ b/apps/web/src/components/icons/ProviderIcons.tsx @@ -0,0 +1,21 @@ +interface IconProps { + size?: number; + className?: string; +} + +export function ClaudeIcon({ size = 14, className }: IconProps) { + return ( + + + + ); +} + +export function OpenCodeIcon({ size = 14, className }: IconProps) { + return ( + + + + + ); +} diff --git a/apps/web/src/components/panes/CoderMessageList.tsx b/apps/web/src/components/panes/CoderMessageList.tsx index 8cf3e03..56172f5 100644 --- a/apps/web/src/components/panes/CoderMessageList.tsx +++ b/apps/web/src/components/panes/CoderMessageList.tsx @@ -1,9 +1,10 @@ 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 { ToolCallLine, type ToolRun } from '@/components/ToolCallLine'; import { AskUserInputCard } from '@/components/AskUserInputCard'; import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools'; +import type { Message } from '@/api/types'; export interface CoderMessageWire { id: string; @@ -141,54 +142,16 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] { 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 ( -
    -
    - {message.content} -
    -
    - ); - } - - return ( -
    - {hasReasoning && ( -
    - Reasoning -
    -            {message.reasoning_text}
    -          
    -
    - )} - {(hasText || (isStreaming && !hasReasoning)) && ( -
    - {hasText ? : null} - {isStreaming && ( - - )} -
    - )} - {message.status === 'failed' && ( -
    message failed
    - )} -
    - ); -} - interface Props { messages: CoderTimelineWire[]; chatId?: string; 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(null); const scrollRef = useRef(null); const isNearBottomRef = useRef(true); @@ -220,7 +183,14 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
    {renderItems.map((item) => { if (item.kind === 'message') { - return ; + return ( + + ); } if (item.kind === 'tool_run') { if (item.run.call.name === 'ask_user_input' && chatId) { diff --git a/apps/web/src/components/panes/CoderPane.tsx b/apps/web/src/components/panes/CoderPane.tsx index d0fc566..437bd63 100644 --- a/apps/web/src/components/panes/CoderPane.tsx +++ b/apps/web/src/components/panes/CoderPane.tsx @@ -4,11 +4,10 @@ // WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502). 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 { PermissionCard } from '@/components/PermissionCard'; -import { AgentCommandsHint } from '@/components/AgentCommandsHint'; -import { SlashCommandPicker } from '@/components/SlashCommandPicker'; +import { ChatInput } from '@/components/ChatInput'; import { api } from '@/api/client'; import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types'; import { useSkills } from '@/hooks/useSkills'; @@ -32,6 +31,8 @@ interface CoderMessage { id: string; function: { name: string; arguments: string }; }>; + ctx_used?: number | null; + ctx_max?: number | null; } interface CoderToolMessage { @@ -63,6 +64,7 @@ interface Props { chatPending?: boolean; projectPath?: string; onConnectedChange?: (connected: boolean) => void; + onAgentLabelChange?: (label: string) => void; } interface WsHandlers { @@ -91,6 +93,8 @@ type RawCoderMessage = { | { id: string; name: string; args?: Record } | { id: string; function: { name: string; arguments: string } } > | null; + ctx_used?: number | null; + ctx_max?: number | null; }; function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null { @@ -126,6 +130,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null status: (raw.status ?? 'complete') as CoderMessage['status'], ...(reasoning_text ? { reasoning_text } : {}), ...(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) => 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, ); if (completed) { @@ -343,7 +354,7 @@ function usePendingChanges(sessionId: string) { useEffect(() => { refresh(); }, [refresh]); 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', }); if (res.ok) { @@ -352,7 +363,7 @@ function usePendingChanges(sessionId: string) { }, [sessionId]); 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', }); if (res.ok) { @@ -463,6 +474,7 @@ export function CoderPane({ chatPending = false, projectPath, onConnectedChange, + onAgentLabelChange, }: Props) { const [agentConfig, setAgentConfig] = useState({ provider: 'boocode', @@ -470,6 +482,12 @@ export function CoderPane({ modeId: 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(null); const [permissionPrompt, setPermissionPrompt] = useState(null); const [permissionBusy, setPermissionBusy] = useState(false); @@ -515,6 +533,8 @@ export function CoderPane({ const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); + const [queue, setQueue] = useState([]); + const queueProcessing = useRef(false); const inputRef = useRef(null); // Refresh pending changes when a message_complete arrives @@ -658,43 +678,87 @@ export function CoderPane({ 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) => { - const newValue = e.target.value; - setInput(newValue); - if (isSlashCommandToken(newValue)) { - setSlashState({ query: slashQuery(newValue) }); - } else { - setSlashState(null); + const sendOneMessage = useCallback(async (text: string) => { + if (!chatId) return; + setSending(true); + setPermissionPrompt(null); + setLiveTaskCommands([]); + + 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( - (e: React.KeyboardEvent) => { - if (slashState) return; - if (e.nativeEvent.isComposing) return; - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - void handleSend(); + // Drain queue when not busy + useEffect(() => { + if (sending || queue.length === 0 || queueProcessing.current) return; + queueProcessing.current = true; + const next = queue[0]!; + setQueue((prev) => prev.slice(1)); + 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 (
    + {/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
    {messages.length === 0 ? ( @@ -706,6 +770,9 @@ export function CoderPane({ { await sendOneMessage(content); }, + }} footer={ activeTaskId && !permissionPrompt && sending === false ? (

    Agent running…

    @@ -738,44 +805,16 @@ export function CoderPane({ {/* Composer + input */}
    - {displayedCommands.length > 0 && } - -
    -
    -