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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (actions?.onRegenerate) {
|
||||||
|
await actions.onRegenerate(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
await api.messages.regenerate(message.chat_id, message.id);
|
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 {
|
||||||
|
if (actions?.onFork) {
|
||||||
|
await actions.onFork(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
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 });
|
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 {
|
||||||
|
if (actions?.onDelete) {
|
||||||
|
await actions.onDelete(message.chat_id, message.id);
|
||||||
|
} else {
|
||||||
await api.messages.remove(message.chat_id, message.id);
|
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,6 +354,7 @@ function ActionRow({
|
|||||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!hiddenSet.has('fork') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void fork()}
|
onClick={() => void fork()}
|
||||||
@@ -313,6 +365,8 @@ function ActionRow({
|
|||||||
>
|
>
|
||||||
<GitFork className="size-3" />
|
<GitFork className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{!hiddenSet.has('delete') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
@@ -323,6 +377,7 @@ function ActionRow({
|
|||||||
>
|
>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
</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>
|
|
||||||
|
|
||||||
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea.
|
|
||||||
chatId is created lazily on first send/slash. */}
|
|
||||||
<div className="border-t px-4 py-3 shrink-0">
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
disabled={false}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
sessionId={sessionId}
|
||||||
|
agentId={agentId ?? null}
|
||||||
|
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>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal file
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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} `;
|
const sendOneMessage = useCallback(async (text: string) => {
|
||||||
setInput(next);
|
if (!chatId) return;
|
||||||
setSlashState(null);
|
setSending(true);
|
||||||
requestAnimationFrame(() => {
|
setPermissionPrompt(null);
|
||||||
const ta = inputRef.current;
|
setLiveTaskCommands([]);
|
||||||
if (ta) {
|
|
||||||
ta.selectionStart = ta.selectionEnd = next.length;
|
const tempId = `temp-${Date.now()}`;
|
||||||
ta.focus();
|
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) =>
|
||||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
||||||
const newValue = e.target.value;
|
|
||||||
setInput(newValue);
|
|
||||||
if (isSlashCommandToken(newValue)) {
|
|
||||||
setSlashState({ query: slashQuery(newValue) });
|
|
||||||
} else {
|
|
||||||
setSlashState(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (slashState) return;
|
|
||||||
if (e.nativeEvent.isComposing) return;
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleSend();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleSend, slashState]
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user