v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes
Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
39
apps/web/src/components/AgentCommandsHint.tsx
Normal file
39
apps/web/src/components/AgentCommandsHint.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { AgentCommand } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
commands: AgentCommand[];
|
||||
}
|
||||
|
||||
export function AgentCommandsHint({ commands }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (commands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-2 mb-1 rounded-md border border-border/60 bg-muted/30 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-muted-foreground hover:text-foreground max-md:min-h-[44px]"
|
||||
>
|
||||
<span>Slash commands ({commands.length})</span>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
{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">
|
||||
{commands.map((cmd) => (
|
||||
<li key={cmd.name} className="font-mono">
|
||||
<span className="text-primary/80">/{cmd.name}</span>
|
||||
{cmd.description && (
|
||||
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
apps/web/src/components/AgentComposerBar.tsx
Normal file
308
apps/web/src/components/AgentComposerBar.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PREFS_KEY = 'boocode.coder.agent-prefs';
|
||||
|
||||
|
||||
type ProviderPrefs = Record<string, {
|
||||
model: string;
|
||||
modeId: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}>;
|
||||
|
||||
function loadPrefs(): ProviderPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY);
|
||||
return raw ? (JSON.parse(raw) as ProviderPrefs) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(prefs: ProviderPrefs): void {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
function defaultsForProvider(entry: ProviderSnapshotEntry): AgentSessionConfig {
|
||||
const model =
|
||||
entry.models.find((m) => m.isDefault)?.id ??
|
||||
entry.models[0]?.id ??
|
||||
'';
|
||||
const selectedModel = entry.models.find((m) => m.id === model);
|
||||
const modeId = entry.defaultModeId ?? entry.modes[0]?.id ?? null;
|
||||
const thinkingOptionId =
|
||||
selectedModel?.defaultThinkingOptionId ??
|
||||
selectedModel?.thinkingOptions?.find((t) => t.isDefault)?.id ??
|
||||
selectedModel?.thinkingOptions?.[0]?.id ??
|
||||
null;
|
||||
|
||||
return {
|
||||
provider: entry.name,
|
||||
model,
|
||||
modeId,
|
||||
thinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfig(
|
||||
entry: ProviderSnapshotEntry,
|
||||
prefs: ProviderPrefs,
|
||||
): AgentSessionConfig {
|
||||
const saved = prefs[entry.name];
|
||||
const base = defaultsForProvider(entry);
|
||||
|
||||
const model =
|
||||
saved?.model && entry.models.some((m) => m.id === saved.model)
|
||||
? saved.model
|
||||
: base.model;
|
||||
|
||||
const selectedModel = entry.models.find((m) => m.id === model);
|
||||
const modeId =
|
||||
saved?.modeId && entry.modes.some((m) => m.id === saved.modeId)
|
||||
? saved.modeId
|
||||
: base.modeId;
|
||||
|
||||
const thinkingOptions = selectedModel?.thinkingOptions ?? [];
|
||||
const thinkingOptionId =
|
||||
saved?.thinkingOptionId &&
|
||||
thinkingOptions.some((t) => t.id === saved.thinkingOptionId)
|
||||
? saved.thinkingOptionId
|
||||
: base.thinkingOptionId;
|
||||
|
||||
return { provider: entry.name, model, modeId, thinkingOptionId };
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
options: Array<{ id: string; label: string }>;
|
||||
onPick: (id: string) => void;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
|
||||
const { isMobile } = useViewport();
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||
|
||||
const list = (
|
||||
<div className="py-1">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{o.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{icon ?? <Cpu className="size-4" />}
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||
<div className="px-2">{list}</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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]"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{currentLabel}</span>
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
||||
{options.map((o) => (
|
||||
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs">
|
||||
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
||||
{o.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectPath?: string;
|
||||
value: AgentSessionConfig;
|
||||
onChange: (next: AgentSessionConfig) => void;
|
||||
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
|
||||
}
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) {
|
||||
const allEntries = useProviderSnapshot(projectPath);
|
||||
const entries = useMemo(
|
||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
||||
[allEntries],
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const hydratedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
hydratedRef.current = false;
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!entries?.length || hydratedRef.current) return;
|
||||
hydratedRef.current = true;
|
||||
const prefs = loadPrefs();
|
||||
const entry =
|
||||
entries.find((e) => e.name === value.provider) ??
|
||||
entries.find((e) => e.name === 'boocode') ??
|
||||
entries[0];
|
||||
if (!entry) return;
|
||||
onChange(resolveConfig(entry, prefs));
|
||||
}, [entries, onChange, value.provider]);
|
||||
|
||||
const currentEntry = useMemo(
|
||||
() => entries?.find((e) => e.name === value.provider),
|
||||
[entries, value.provider],
|
||||
);
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => currentEntry?.models.find((m) => m.id === value.model),
|
||||
[currentEntry, value.model],
|
||||
);
|
||||
|
||||
const thinkingOptions = currentModel?.thinkingOptions ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
onProviderCommandsChange?.(currentEntry?.commands ?? []);
|
||||
}, [currentEntry, onProviderCommandsChange]);
|
||||
|
||||
function persist(next: AgentSessionConfig): void {
|
||||
const prefs = loadPrefs();
|
||||
prefs[next.provider] = {
|
||||
model: next.model,
|
||||
modeId: next.modeId,
|
||||
thinkingOptionId: next.thinkingOptionId,
|
||||
};
|
||||
savePrefs(prefs);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function pickProvider(name: string): void {
|
||||
const entry = entries?.find((e) => e.name === name);
|
||||
if (!entry) return;
|
||||
persist(resolveConfig(entry, loadPrefs()));
|
||||
}
|
||||
|
||||
function pickModel(model: string): void {
|
||||
const entry = currentEntry;
|
||||
if (!entry) return;
|
||||
const selected = entry.models.find((m) => m.id === model);
|
||||
const thinkingOptionId =
|
||||
selected?.defaultThinkingOptionId ??
|
||||
selected?.thinkingOptions?.find((t) => t.isDefault)?.id ??
|
||||
selected?.thinkingOptions?.[0]?.id ??
|
||||
null;
|
||||
persist({ ...value, model, thinkingOptionId });
|
||||
}
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await api.coder.refreshProviders();
|
||||
await refreshProviderSnapshot(projectPath);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">Loading agents…</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }));
|
||||
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
||||
<CompactPicker
|
||||
label="Provider"
|
||||
value={value.provider}
|
||||
options={providerOptions}
|
||||
onPick={pickProvider}
|
||||
icon={<Cpu className="size-3 shrink-0" />}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
value={value.modeId ?? ''}
|
||||
disabled={modeOptions.length === 0}
|
||||
options={modeOptions}
|
||||
onPick={(modeId) => persist({ ...value, modeId })}
|
||||
icon={<Shield className="size-3 shrink-0" />}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Model"
|
||||
value={value.model}
|
||||
disabled={modelOptions.length === 0}
|
||||
options={modelOptions}
|
||||
onPick={pickModel}
|
||||
/>
|
||||
{thinkingOpts.length > 0 && (
|
||||
<CompactPicker
|
||||
label="Thinking"
|
||||
value={value.thinkingOptionId ?? ''}
|
||||
options={thinkingOpts}
|
||||
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
|
||||
icon={<Brain className="size-3 shrink-0" />}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
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"
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||
import { DropOverlay } from '@/components/DropOverlay';
|
||||
import { AgentPicker } from '@/components/AgentPicker';
|
||||
import { ContextBar } from '@/components/ContextBar';
|
||||
import { SkillSlashCommand } from '@/components/SkillSlashCommand';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { api } from '@/api/client';
|
||||
import type { Message } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -87,10 +88,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
|
||||
// the input and stays open while the input is `/<word>` with no whitespace.
|
||||
// Disabled entirely when the caller doesn't pass onSlashCommand.
|
||||
// v1.12 CP7.5: anchorRect was a snapshot taken at open time. SkillSlashCommand
|
||||
// now reads the live textarea rect via inputRef (textareaRef below) so it can
|
||||
// recompute on visualViewport changes (iOS keyboard open/close), so the
|
||||
// anchorRect field is no longer needed in this state.
|
||||
// SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below)
|
||||
// so it can recompute on visualViewport changes (iOS keyboard open/close).
|
||||
const [slashState, setSlashState] = useState<{
|
||||
query: string;
|
||||
} | null>(null);
|
||||
@@ -168,13 +167,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
// input parses to a known skill. Falls through to onSend for unknown
|
||||
// slash names (literal text) or when slash dispatch isn't wired.
|
||||
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
|
||||
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
|
||||
if (match && skillsLookup.has(match[1]!)) {
|
||||
const skillName = match[1]!;
|
||||
const args = (match[2] ?? '').trim();
|
||||
const parsed = parseSlashInput(text);
|
||||
if (parsed && skillsLookup.has(parsed.cmdName)) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSlashCommand(skillName, args);
|
||||
await onSlashCommand(parsed.cmdName, parsed.args);
|
||||
setValue('');
|
||||
setAttachments([]);
|
||||
setSlashState(null);
|
||||
@@ -268,8 +265,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
// slash-prefixed token with no whitespace (i.e. user is still typing the
|
||||
// skill name). Hand off to args mode the moment a space appears or the
|
||||
// slash leaves position 0.
|
||||
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
|
||||
const query = newValue.slice(1);
|
||||
if (onSlashCommand && isSlashCommandToken(newValue)) {
|
||||
const query = slashQuery(newValue);
|
||||
if (!slashState) {
|
||||
setSlashState({ query });
|
||||
} else if (slashState.query !== query) {
|
||||
@@ -496,7 +493,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (mentionState?.open) return;
|
||||
// SkillSlashCommand owns Arrow/Enter/Tab/Esc via a document listener; let
|
||||
// SlashCommandPicker owns Arrow/Enter/Tab/Esc via a document listener; let
|
||||
// it consume them so the textarea doesn't also submit on Enter.
|
||||
if (slashState) return;
|
||||
// IME safety: never act on Enter while an IME composition is in flight
|
||||
@@ -658,12 +655,13 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
/>
|
||||
)}
|
||||
{slashState && (
|
||||
<SkillSlashCommand
|
||||
<SlashCommandPicker
|
||||
query={slashState.query}
|
||||
skills={skills}
|
||||
items={skills}
|
||||
inputRef={textareaRef}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
emptyLabel="No skills available"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -183,13 +183,13 @@ export function ChatTabBar({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New chat
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New terminal
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New coder
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
@@ -37,14 +38,15 @@ function useTerminals(): TerminalRegistration[] {
|
||||
return list;
|
||||
}
|
||||
|
||||
// Wrap a message body with a right-click context menu offering "Send to
|
||||
// terminal → <pane name>". The submenu is disabled when nothing is selected
|
||||
// or no terminal panes are open; clicking a target emits a sendToTerminal
|
||||
// event that TerminalPane subscribes to (filtered by pane_id).
|
||||
// Wrap a message body with a right-click context menu offering Copy and
|
||||
// "Send to terminal → <pane name>". Send is disabled when nothing is
|
||||
// selected or no terminal panes are open; clicking a target emits a
|
||||
// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id).
|
||||
function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
||||
const [selection, setSelection] = useState('');
|
||||
const terminals = useTerminals();
|
||||
const canSend = selection.length > 0 && terminals.length > 0;
|
||||
const hasSelection = selection.length > 0;
|
||||
const canSend = hasSelection && terminals.length > 0;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
@@ -57,6 +59,17 @@ function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
disabled={!hasSelection}
|
||||
onSelect={() => {
|
||||
void navigator.clipboard.writeText(selection).catch((err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'copy failed');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
|
||||
@@ -29,13 +29,13 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New chat
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New terminal
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New coder
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
49
apps/web/src/components/PermissionCard.tsx
Normal file
49
apps/web/src/components/PermissionCard.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import type { PermissionPrompt } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
prompt: PermissionPrompt;
|
||||
onRespond: (optionId: string | null) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function PermissionCard({ prompt, onRespond, busy }: Props) {
|
||||
return (
|
||||
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground">Permission required</p>
|
||||
{prompt.toolTitle && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{prompt.toolTitle}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{prompt.options.map((opt) => (
|
||||
<button
|
||||
key={opt.optionId}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onRespond(opt.optionId)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-2.5 py-1 text-xs hover:bg-accent',
|
||||
'max-md:min-h-[44px] disabled:opacity-40',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onRespond(null)}
|
||||
className="rounded-md border border-destructive/40 px-2.5 py-1 text-xs text-destructive hover:bg-destructive/10 max-md:min-h-[44px] disabled:opacity-40"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X } from 'lucide-react';
|
||||
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -26,6 +26,7 @@ import { useViewport } from '@/hooks/useViewport';
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import type { SidebarProject } from '@/api/types';
|
||||
import { giteaUrlFor } from '@/lib/projectUrls';
|
||||
import { isCoderSessionName } from '@/lib/coder-session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EXPANDED_KEY = 'boocode.sidebar.expanded';
|
||||
@@ -382,7 +383,11 @@ export function ProjectSidebar() {
|
||||
to={`/session/${s.id}`}
|
||||
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
|
||||
>
|
||||
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
|
||||
{isCoderSessionName(s.name) ? (
|
||||
<Code className="size-3.5 shrink-0 opacity-70" />
|
||||
) : (
|
||||
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
|
||||
)}
|
||||
<span className="truncate flex-1" title={s.name}>{s.name}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
{relTime(s.updated_at)}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Provider } from '@/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
interface Props {
|
||||
provider: string;
|
||||
model: string;
|
||||
onChange: (provider: string, model: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function ProviderModelList({
|
||||
providers,
|
||||
error,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
onPick,
|
||||
}: {
|
||||
providers: Provider[] | null;
|
||||
error: string | null;
|
||||
currentProvider: string;
|
||||
currentModel: string;
|
||||
onPick: (provider: string, model: string) => void;
|
||||
}) {
|
||||
if (error) {
|
||||
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
|
||||
}
|
||||
if (providers === null) {
|
||||
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const singleProvider = providers.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{providers.map((p) => (
|
||||
<div key={p.name}>
|
||||
{!singleProvider && (
|
||||
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{p.label}
|
||||
</div>
|
||||
)}
|
||||
{p.models.map((m) => (
|
||||
<button
|
||||
key={`${p.name}:${m.id}`}
|
||||
type="button"
|
||||
onClick={() => onPick(p.name, m.id)}
|
||||
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
|
||||
>
|
||||
<Check
|
||||
className={`size-3 shrink-0 ${
|
||||
p.name === currentProvider && m.id === currentModel
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderPicker({ provider, model, onChange }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [providers, setProviders] = useState<Provider[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || providers !== null) return;
|
||||
api.coder
|
||||
.providers()
|
||||
.then(setProviders)
|
||||
.catch((err) =>
|
||||
setError(err instanceof Error ? err.message : 'failed to load providers'),
|
||||
);
|
||||
}, [open, providers]);
|
||||
|
||||
function handlePick(prov: string, mod: string) {
|
||||
setOpen(false);
|
||||
void onChange(prov, mod);
|
||||
}
|
||||
|
||||
const currentProviderLabel =
|
||||
providers?.find((p) => p.name === provider)?.label ?? provider;
|
||||
|
||||
const triggerText = providers && providers.length > 1
|
||||
? `${currentProviderLabel} / ${model}`
|
||||
: model;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Provider: ${currentProviderLabel}, Model: ${model}`}
|
||||
title={`${currentProviderLabel} / ${model}`}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Cpu className="size-4" />
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title="Provider / Model">
|
||||
<div className="px-2 py-2 space-y-1">
|
||||
<ProviderModelList
|
||||
providers={providers}
|
||||
error={error}
|
||||
currentProvider={provider}
|
||||
currentModel={model}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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 max-w-[260px]"
|
||||
>
|
||||
<span className="truncate">{triggerText}</span>
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto min-w-[200px]">
|
||||
{error && (
|
||||
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
{providers === null && !error && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>
|
||||
)}
|
||||
{providers && providers.map((p) => {
|
||||
const singleProvider = providers.length === 1;
|
||||
return (
|
||||
<div key={p.name}>
|
||||
{!singleProvider && (
|
||||
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70 select-none">
|
||||
{p.label}
|
||||
</div>
|
||||
)}
|
||||
{p.models.map((m) => (
|
||||
<DropdownMenuItem
|
||||
key={`${p.name}:${m.id}`}
|
||||
onSelect={() => handlePick(p.name, m.id)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
<Check
|
||||
className={`size-3 shrink-0 ${
|
||||
p.name === provider && m.id === model
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{m.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Skill } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
skills: Skill[];
|
||||
// v1.12 CP7.5: was `anchorRect: {top, left}` (snapshot at open time). Now a
|
||||
// live ref so the dropdown can re-stat the input on visualViewport events —
|
||||
// critical on iOS where the keyboard shifts the visual viewport and the
|
||||
// dropdown would otherwise sit in the wrong place (often hidden).
|
||||
inputRef: RefObject<HTMLElement | null>;
|
||||
onSelect: (skillName: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// max-h-[320px] on the popover — use as the height budget for above/below
|
||||
// fit decisions. Slightly under-estimates when the list is short, but the
|
||||
// only consequence is we sometimes flip below when we'd fit above; no UX
|
||||
// breakage either way.
|
||||
const DROPDOWN_HEIGHT_BUDGET = 320;
|
||||
|
||||
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
|
||||
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
|
||||
// `Command` (cmdk) isn't installed in this project; per the addendum we use
|
||||
// a plain div + Tailwind instead of pulling a new primitive autonomously.
|
||||
//
|
||||
// v1.12 CP7.5: portalled to document.body (escapes transformed/will-change
|
||||
// ancestor stacking contexts that hid the popover inside ChatInput on iOS)
|
||||
// + visualViewport-aware positioning (handles keyboard open/close + the iOS
|
||||
// "shift layout to keep input visible" auto-scroll).
|
||||
|
||||
// Case-insensitive prefix match on `name` only. Description is display-only
|
||||
// in v1 (substring search across description is deferred to a polish batch).
|
||||
function filterByPrefix(skills: Skill[], query: string): Skill[] {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = q
|
||||
? skills.filter((s) => s.name.toLowerCase().startsWith(q))
|
||||
: skills;
|
||||
// Stable alphabetical ordering matches the server's cache order (skills.ts
|
||||
// sorts on name asc) but we re-sort here so a stale client cache doesn't
|
||||
// surprise the user.
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function SkillSlashCommand({ query, skills, inputRef, onSelect, onClose }: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
|
||||
|
||||
// Anchor + viewport tracking. `rect` is the input's bounding rect in layout
|
||||
// viewport coords. `vvTick` forces a re-render whenever visualViewport
|
||||
// changes even if the rect itself didn't (e.g. user scrolled the visual
|
||||
// viewport without the input moving in layout space).
|
||||
const [rect, setRect] = useState<DOMRect | null>(
|
||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||
);
|
||||
const [vvTick, setVvTick] = useState(0);
|
||||
|
||||
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||
|
||||
// v1.12 CP7.5: recalc on viewport changes. iOS Safari fires
|
||||
// visualViewport.resize when the soft keyboard opens/closes; .scroll fires
|
||||
// when the page is shifted to keep the focused input visible above the
|
||||
// keyboard. Both events should trigger a position recompute.
|
||||
useEffect(() => {
|
||||
function recalc() {
|
||||
setRect(inputRef.current?.getBoundingClientRect() ?? null);
|
||||
setVvTick((t) => t + 1);
|
||||
}
|
||||
recalc();
|
||||
const vv = window.visualViewport;
|
||||
vv?.addEventListener('resize', recalc);
|
||||
vv?.addEventListener('scroll', recalc);
|
||||
window.addEventListener('resize', recalc);
|
||||
return () => {
|
||||
vv?.removeEventListener('resize', recalc);
|
||||
vv?.removeEventListener('scroll', recalc);
|
||||
window.removeEventListener('resize', recalc);
|
||||
};
|
||||
}, [inputRef]);
|
||||
|
||||
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
|
||||
// textarea reach the popover even though focus stays in the textarea.
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (filtered.length === 0) return;
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightIndex] ?? filtered[0];
|
||||
if (target) onSelect(target.name);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
// v1.12 CP7.5: visualViewport-corrected positioning. getBoundingClientRect
|
||||
// returns layout-viewport coords; iOS Safari's `position: fixed` positions
|
||||
// relative to the layout viewport too — but the visible area can be offset
|
||||
// (vv.offsetTop/offsetLeft) when iOS scrolls the input above the keyboard.
|
||||
// Subtracting the vv offsets keeps the dropdown locked to the input's
|
||||
// visual position. vvTick is in the dep list to force recompute on
|
||||
// visualViewport events even when the rect itself didn't change.
|
||||
//
|
||||
// Default: position above the input (matches original UX). Flip below if
|
||||
// above doesn't fit (input too close to top of visible viewport). When
|
||||
// below would overlap the keyboard, cap top so the dropdown stays visible.
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
if (!rect) return { display: 'none' };
|
||||
const vv = window.visualViewport;
|
||||
const vvOffsetTop = vv?.offsetTop ?? 0;
|
||||
const vvOffsetLeft = vv?.offsetLeft ?? 0;
|
||||
const vvHeight = vv?.height ?? window.innerHeight;
|
||||
|
||||
const anchorTop = rect.top - vvOffsetTop;
|
||||
const anchorBottom = rect.bottom - vvOffsetTop;
|
||||
const left = rect.left - vvOffsetLeft;
|
||||
|
||||
const fitsAbove = anchorTop >= DROPDOWN_HEIGHT_BUDGET;
|
||||
if (fitsAbove) {
|
||||
// translate(-100%) on Y so the dropdown grows upward from anchorTop.
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: anchorTop,
|
||||
left,
|
||||
transform: 'translateY(-100%)',
|
||||
};
|
||||
}
|
||||
// Render below; clamp so the bottom edge stays inside the visible viewport.
|
||||
const maxTop = Math.max(0, vvHeight - DROPDOWN_HEIGHT_BUDGET);
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: Math.min(anchorBottom, maxTop),
|
||||
left,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rect, vvTick]);
|
||||
|
||||
const popover = filtered.length === 0 ? (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
||||
style={style}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{query ? `No skill starts with "/${query}"` : 'No skills available'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((skill, i) => (
|
||||
<button
|
||||
key={skill.name}
|
||||
type="button"
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
// mousedown not click — click runs after blur/focus shuffles which
|
||||
// can race with the textarea's onBlur close path.
|
||||
e.preventDefault();
|
||||
onSelect(skill.name);
|
||||
}}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{skill.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// v1.12 CP7.5: portal to document.body to escape ChatInput's stacking
|
||||
// context. The original render-in-place rendered the dropdown inside the
|
||||
// composer's transformed/will-change ancestor tree, which on iOS Safari +
|
||||
// Vivaldi caused the popover to either disappear or sit at z-index 0
|
||||
// behind the autofill toolbar. document.body has no transform ancestor.
|
||||
return createPortal(popover, document.body);
|
||||
}
|
||||
181
apps/web/src/components/SlashCommandPicker.tsx
Normal file
181
apps/web/src/components/SlashCommandPicker.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SlashCommandItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
items: SlashCommandItem[];
|
||||
inputRef: RefObject<HTMLElement | null>;
|
||||
onSelect: (name: string) => void;
|
||||
onClose: () => void;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
const DROPDOWN_HEIGHT_BUDGET = 320;
|
||||
|
||||
function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandItem[] {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = q ? items.filter((s) => s.name.toLowerCase().startsWith(q)) : items;
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function SlashCommandPicker({
|
||||
query,
|
||||
items,
|
||||
inputRef,
|
||||
onSelect,
|
||||
onClose,
|
||||
emptyLabel = 'No commands available',
|
||||
}: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
|
||||
|
||||
const [rect, setRect] = useState<DOMRect | null>(
|
||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||
);
|
||||
const [vvTick, setVvTick] = useState(0);
|
||||
|
||||
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
function recalc() {
|
||||
setRect(inputRef.current?.getBoundingClientRect() ?? null);
|
||||
setVvTick((t) => t + 1);
|
||||
}
|
||||
recalc();
|
||||
const vv = window.visualViewport;
|
||||
vv?.addEventListener('resize', recalc);
|
||||
vv?.addEventListener('scroll', recalc);
|
||||
window.addEventListener('resize', recalc);
|
||||
return () => {
|
||||
vv?.removeEventListener('resize', recalc);
|
||||
vv?.removeEventListener('scroll', recalc);
|
||||
window.removeEventListener('resize', recalc);
|
||||
};
|
||||
}, [inputRef]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (filtered.length === 0) return;
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightIndex] ?? filtered[0];
|
||||
if (target) onSelect(target.name);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
if (!rect) return { display: 'none' };
|
||||
const vv = window.visualViewport;
|
||||
const vvOffsetTop = vv?.offsetTop ?? 0;
|
||||
const vvHeight = vv?.height ?? window.innerHeight;
|
||||
// Visible region in layout-viewport coords (what position:fixed uses)
|
||||
const visibleTop = vvOffsetTop;
|
||||
const visibleBottom = vvOffsetTop + vvHeight;
|
||||
|
||||
const spaceAbove = rect.top - visibleTop;
|
||||
const spaceBelow = visibleBottom - rect.bottom;
|
||||
|
||||
if (spaceAbove >= Math.min(DROPDOWN_HEIGHT_BUDGET, spaceBelow)) {
|
||||
// Place above: clamp to visible top
|
||||
const popupTop = Math.max(visibleTop, rect.top - DROPDOWN_HEIGHT_BUDGET);
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: popupTop,
|
||||
left: rect.left,
|
||||
maxHeight: rect.top - popupTop,
|
||||
};
|
||||
}
|
||||
// Place below: clamp to visible bottom
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: rect.bottom,
|
||||
left: rect.left,
|
||||
maxHeight: Math.min(DROPDOWN_HEIGHT_BUDGET, visibleBottom - rect.bottom),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rect, vvTick]);
|
||||
|
||||
const popover = filtered.length === 0 ? (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
||||
style={style}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
{query ? `No command starts with "/${query}"` : emptyLabel}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((item, i) => (
|
||||
<div
|
||||
key={item.name}
|
||||
role="option"
|
||||
aria-selected={i === highlightIndex}
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onClick={() => onSelect(item.name)}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||
{item.description && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(popover, document.body);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
|
||||
import { MessageSquare, Terminal, Code, Clipboard, Plus, X } from 'lucide-react';
|
||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { terminalsRegistry } from '@/lib/events';
|
||||
@@ -34,6 +34,8 @@ interface Props {
|
||||
// v1.9: passed through to SettingsPane when one is mounted in the grid.
|
||||
session: Session;
|
||||
project: Project | null;
|
||||
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
}
|
||||
|
||||
export function Workspace({
|
||||
@@ -45,6 +47,7 @@ export function Workspace({
|
||||
chatsHook,
|
||||
session,
|
||||
project,
|
||||
onAddPane,
|
||||
}: Props) {
|
||||
const {
|
||||
panes,
|
||||
@@ -59,6 +62,7 @@ export function Workspace({
|
||||
showLandingPage,
|
||||
addSplitPane,
|
||||
removePane,
|
||||
isPaneChatPending,
|
||||
handlePaneDragStart,
|
||||
handlePaneDragOver,
|
||||
handlePaneDragLeave,
|
||||
@@ -134,44 +138,11 @@ export function Workspace({
|
||||
return out;
|
||||
}, [panes]);
|
||||
|
||||
// Per-coder-pane WS connection (status dot lives in the pane header).
|
||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{!isMobile && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
// v1.9: settings panes excluded from the MAX cap (decision c).
|
||||
disabled={panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||
panes.filter((p) => p.kind !== 'settings').length >= MAX_PANES &&
|
||||
'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
Split
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||
<MessageSquare size={14} /> Chat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||
<Terminal size={14} /> Terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
|
||||
<Code size={14} /> Coder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header
|
||||
pill (MobileTabSwitcher) is the mobile pane switcher. */}
|
||||
|
||||
<div
|
||||
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
|
||||
style={
|
||||
@@ -185,6 +156,7 @@ export function Workspace({
|
||||
{panes.map((pane, idx) => {
|
||||
const isSettings = pane.kind === 'settings';
|
||||
const isTerminal = pane.kind === 'terminal';
|
||||
const isCoder = pane.kind === 'coder';
|
||||
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
|
||||
// v1.9: when maximized, hide every pane except the settings one.
|
||||
// display:none keeps the React tree mounted so streams / drafts
|
||||
@@ -197,9 +169,8 @@ export function Workspace({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
|
||||
// are not drag-reorderable for now — keeps the layout grid simple.
|
||||
const isChromeless = isSettings || isTerminal || isArtifact;
|
||||
// Terminal + coder panes own their tab strip (no chats, no ChatTabBar).
|
||||
const isChromeless = isSettings || isTerminal || isCoder || isArtifact;
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
@@ -233,13 +204,66 @@ export function Workspace({
|
||||
onCloseAll={() => closeAllTabs(idx)}
|
||||
onAddPane={(kind) => {
|
||||
if (kind === 'chat') void createChat(idx);
|
||||
else addSplitPane(kind);
|
||||
else onAddPane(kind);
|
||||
}}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
)}
|
||||
{isCoder && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<Code size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">BooCode</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
aria-label="New pane"
|
||||
title="New pane"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePane(idx);
|
||||
}}
|
||||
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} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isTerminal && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<Terminal size={12} className="text-muted-foreground" />
|
||||
@@ -259,14 +283,14 @@ export function Workspace({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||
<MessageSquare size={14} /> New chat
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||
<Terminal size={14} /> New terminal
|
||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('coder')}>
|
||||
<Code size={14} /> New coder
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -323,7 +347,18 @@ export function Workspace({
|
||||
active={idx === activePaneIdx}
|
||||
/>
|
||||
) : pane.kind === 'coder' ? (
|
||||
<CoderPane sessionId={sessionId} />
|
||||
<CoderPane
|
||||
sessionId={sessionId}
|
||||
paneId={pane.id}
|
||||
chatId={activePaneChatId(pane)}
|
||||
chatPending={isPaneChatPending(pane.id)}
|
||||
projectPath={project?.path}
|
||||
onConnectedChange={(connected) =>
|
||||
setCoderConnected((prev) =>
|
||||
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
||||
<MarkdownArtifactPane
|
||||
chatId={pane.markdown_artifact_state.chat_id}
|
||||
|
||||
228
apps/web/src/components/panes/CoderMessageList.tsx
Normal file
228
apps/web/src/components/panes/CoderMessageList.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||
import { ToolCallGroup } from '@/components/ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
||||
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
||||
|
||||
export interface CoderMessageWire {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
reasoning_text?: string;
|
||||
tool_calls?: CoderToolCallWire[];
|
||||
}
|
||||
|
||||
export interface CoderToolMessageWire {
|
||||
id: string;
|
||||
role: 'tool';
|
||||
tool_results: {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CoderTimelineWire = CoderMessageWire | CoderToolMessageWire;
|
||||
|
||||
function isToolMessage(m: CoderTimelineWire): m is CoderToolMessageWire {
|
||||
return m.role === 'tool';
|
||||
}
|
||||
|
||||
type RenderItem =
|
||||
| { kind: 'message'; message: CoderMessageWire }
|
||||
| { kind: 'tool_run'; run: ToolRun; key: string }
|
||||
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
||||
|
||||
const GROUP_THRESHOLD = 3;
|
||||
const SCROLL_THRESHOLD_PX = 150;
|
||||
|
||||
function flattenCoderMessages(messages: CoderTimelineWire[]): RenderItem[] {
|
||||
const items: RenderItem[] = [];
|
||||
const runsByCallId = new Map<string, ToolRun>();
|
||||
|
||||
for (const m of messages) {
|
||||
if (isToolMessage(m)) {
|
||||
const run = runsByCallId.get(m.tool_results.tool_call_id);
|
||||
if (run) {
|
||||
run.result = {
|
||||
tool_call_id: m.tool_results.tool_call_id,
|
||||
output: m.tool_results.output,
|
||||
truncated: m.tool_results.truncated ?? false,
|
||||
...(m.tool_results.error ? { error: m.tool_results.error } : {}),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m.role === 'user' || m.role === 'system') {
|
||||
items.push({ kind: 'message', message: m });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasToolCalls = (m.tool_calls?.length ?? 0) > 0;
|
||||
const hasText = m.content.trim().length > 0;
|
||||
const hasReasoning = (m.reasoning_text?.trim().length ?? 0) > 0;
|
||||
// External agents persist tool calls + final answer on one row. Render tools
|
||||
// before the answer text so the timeline matches BooChat (tools, then reply).
|
||||
const externalCombined = hasToolCalls && (hasText || hasReasoning);
|
||||
|
||||
if (externalCombined) {
|
||||
if (hasReasoning) {
|
||||
items.push({
|
||||
kind: 'message',
|
||||
message: { ...m, content: '', reasoning_text: m.reasoning_text },
|
||||
});
|
||||
}
|
||||
for (const tc of m.tool_calls!) {
|
||||
const run = wireToolCallToRun(tc);
|
||||
runsByCallId.set(tc.id, run);
|
||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
||||
}
|
||||
if (hasText || m.status === 'streaming') {
|
||||
items.push({
|
||||
kind: 'message',
|
||||
message: { ...m, reasoning_text: undefined },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Native inference: separate assistant rows per step — mirror MessageList.
|
||||
if (hasText || hasReasoning || m.status === 'streaming') {
|
||||
items.push({ kind: 'message', message: m });
|
||||
}
|
||||
if (hasToolCalls) {
|
||||
for (const tc of m.tool_calls!) {
|
||||
const run = wireToolCallToRun(tc);
|
||||
runsByCallId.set(tc.id, run);
|
||||
items.push({ kind: 'tool_run', run, key: tc.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function groupToolRuns(items: RenderItem[]): RenderItem[] {
|
||||
const out: RenderItem[] = [];
|
||||
let i = 0;
|
||||
while (i < items.length) {
|
||||
const item = items[i]!;
|
||||
if (item.kind !== 'tool_run') {
|
||||
out.push(item);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const name = item.run.call.name;
|
||||
let j = i + 1;
|
||||
while (
|
||||
j < items.length &&
|
||||
items[j]!.kind === 'tool_run' &&
|
||||
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
|
||||
) {
|
||||
j += 1;
|
||||
}
|
||||
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
|
||||
if (run.length >= GROUP_THRESHOLD) {
|
||||
out.push({ kind: 'tool_group', runs: run.map((r) => r.run), key: `group-${run[0]!.key}` });
|
||||
} else {
|
||||
for (const r of run) out.push(r);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
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 {
|
||||
messages: CoderTimelineWire[];
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function CoderMessageList({ messages, footer }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
|
||||
const renderItems = useMemo(
|
||||
() => groupToolRuns(flattenCoderMessages(messages)),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
isNearBottomRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNearBottomRef.current) {
|
||||
endRef.current?.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollRef} onScroll={handleScroll}>
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'message') {
|
||||
return <CoderTextBubble key={item.message.id} message={item.message} />;
|
||||
}
|
||||
if (item.kind === 'tool_run') {
|
||||
return <ToolCallLine key={item.key} run={item.run} />;
|
||||
}
|
||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||
})}
|
||||
{footer}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
// v2.0.0: BooCoder pane — renders the BooCoder chat + diff interface inside
|
||||
// BooChat's multi-pane workspace.
|
||||
// BooCoder pane — chat + diff inside BooChat's multi-pane workspace.
|
||||
//
|
||||
// Architecture:
|
||||
// - REST calls go through /api/coder/* which BooChat's server proxies to
|
||||
// the boocoder container at http://boocoder:3000/api/*
|
||||
// - WS connects directly to the boocoder container at :9502 (same Tailscale
|
||||
// network, no CORS for WebSocket). In dev, the Vite proxy handles it.
|
||||
// REST: /api/coder/* proxied by BooChat to host boocoder.service (:9502).
|
||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||
import { ProviderPicker } from '@/components/ProviderPicker';
|
||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||
import { PermissionCard } from '@/components/PermissionCard';
|
||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||
import { useSkills } from '@/hooks/useSkills';
|
||||
import { toast } from 'sonner';
|
||||
import { isSlashCommandToken, mergeCommandsByName, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { mergeWireToolCall } from '@/lib/coder-tools';
|
||||
import { CoderMessageList, type CoderTimelineWire } from '@/components/panes/CoderMessageList';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,16 +27,26 @@ interface CoderMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
reasoning_text?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
tool_results?: {
|
||||
}
|
||||
|
||||
interface CoderToolMessage {
|
||||
id: string;
|
||||
role: 'tool';
|
||||
tool_results: {
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type CoderTimelineMessage = CoderMessage | CoderToolMessage;
|
||||
|
||||
interface PendingChange {
|
||||
id: string;
|
||||
file_path: string;
|
||||
@@ -43,24 +58,106 @@ interface PendingChange {
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
chatId?: string;
|
||||
chatPending?: boolean;
|
||||
projectPath?: string;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
interface WsHandlers {
|
||||
onPermissionRequested?: (prompt: PermissionPrompt) => void;
|
||||
onPermissionResolved?: (taskId: string) => void;
|
||||
onAssistantComplete?: () => void;
|
||||
onAgentCommands?: (taskId: string, commands: AgentCommand[]) => void;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
}
|
||||
|
||||
function useCoderMessages(sessionId: string) {
|
||||
const [messages, setMessages] = useState<CoderMessage[]>([]);
|
||||
type RawCoderMessage = {
|
||||
id: string;
|
||||
role: string;
|
||||
chat_id?: string;
|
||||
content?: string | null;
|
||||
status?: string | null;
|
||||
reasoning_text?: string;
|
||||
reasoning_parts?: Array<{ text?: string }> | null;
|
||||
tool_results?: {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
} | null;
|
||||
tool_calls?: Array<
|
||||
| { id: string; name: string; args?: Record<string, unknown> }
|
||||
| { id: string; function: { name: string; arguments: string } }
|
||||
> | null;
|
||||
};
|
||||
|
||||
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
||||
if (raw.role === 'tool') {
|
||||
if (!raw.tool_results?.tool_call_id) return null;
|
||||
return {
|
||||
id: raw.id,
|
||||
role: 'tool',
|
||||
tool_results: raw.tool_results,
|
||||
};
|
||||
}
|
||||
if (raw.role !== 'user' && raw.role !== 'assistant' && raw.role !== 'system') return null;
|
||||
const tool_calls = raw.tool_calls?.map((tc) => {
|
||||
if ('function' in tc) {
|
||||
return { id: tc.id, function: tc.function };
|
||||
}
|
||||
return {
|
||||
id: tc.id,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.args ?? {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const reasoning_text =
|
||||
raw.reasoning_text ??
|
||||
raw.reasoning_parts?.map((p) => p.text ?? '').join('') ??
|
||||
'';
|
||||
return {
|
||||
id: raw.id,
|
||||
role: raw.role as CoderMessage['role'],
|
||||
content: raw.content ?? '',
|
||||
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
||||
...(reasoning_text ? { reasoning_text } : {}),
|
||||
...(tool_calls?.length ? { tool_calls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function useCoderMessages(sessionId: string, chatId: string | undefined, handlers: WsHandlers) {
|
||||
const [messages, setMessages] = useState<CoderTimelineMessage[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
const chatIdRef = useRef(chatId);
|
||||
chatIdRef.current = chatId;
|
||||
|
||||
const loadMessages = useCallback(() => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return api.coder
|
||||
.listMessages(sessionId, chatId)
|
||||
.then((rows) =>
|
||||
setMessages(
|
||||
rows
|
||||
.map(mapCoderTimelineRow)
|
||||
.filter((m): m is CoderTimelineMessage => m !== null),
|
||||
),
|
||||
)
|
||||
.catch(() => {/* boocoder may be down */});
|
||||
}, [sessionId, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch existing messages on mount
|
||||
fetch(`/api/coder/sessions/${sessionId}/messages`)
|
||||
.then((res) => res.ok ? res.json() : [])
|
||||
.then((data: CoderMessage[]) => setMessages(data))
|
||||
.catch(() => {/* noop — coder backend may not be running */});
|
||||
}, [sessionId]);
|
||||
void loadMessages();
|
||||
}, [loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
// WS connects to the coder backend. In production, this goes through the
|
||||
@@ -77,38 +174,137 @@ function useCoderMessages(sessionId: string) {
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const frame = JSON.parse(ev.data as string);
|
||||
if (frame.type === 'message_started') {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: frame.message_id, role: frame.role ?? 'assistant', content: '', status: 'streaming' },
|
||||
]);
|
||||
const scopedChatId = chatIdRef.current;
|
||||
if (
|
||||
scopedChatId &&
|
||||
frame.chat_id &&
|
||||
frame.chat_id !== scopedChatId &&
|
||||
frame.type !== 'snapshot'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (frame.type === 'snapshot' && Array.isArray(frame.messages)) {
|
||||
const rawMessages = (frame.messages as RawCoderMessage[]).filter(
|
||||
(m) => !scopedChatId || m.chat_id === scopedChatId,
|
||||
);
|
||||
setMessages(
|
||||
rawMessages
|
||||
.map(mapCoderTimelineRow)
|
||||
.filter((m): m is CoderTimelineMessage => m !== null),
|
||||
);
|
||||
} else if (frame.type === 'message_started') {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === frame.message_id)) return prev;
|
||||
const role = frame.role ?? 'assistant';
|
||||
const tempIdx =
|
||||
role === 'user'
|
||||
? prev.findIndex((m) => m.id.startsWith('temp-') && m.role === 'user')
|
||||
: -1;
|
||||
if (tempIdx >= 0) {
|
||||
return prev.map((m, i) =>
|
||||
i === tempIdx ? { ...m, id: frame.message_id, status: 'streaming' } : m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: frame.message_id, role, content: '', status: 'streaming' },
|
||||
];
|
||||
});
|
||||
} else if (frame.type === 'delta') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? { ...m, content: m.content + (frame.content ?? '') }
|
||||
: m
|
||||
)
|
||||
prev.map((m) => {
|
||||
if (m.id !== frame.message_id || m.role === 'tool') return m;
|
||||
const chunk = frame.content ?? '';
|
||||
if (m.role === 'user') {
|
||||
return { ...m, content: chunk || m.content };
|
||||
}
|
||||
return { ...m, content: m.content + chunk };
|
||||
}),
|
||||
);
|
||||
} else if (frame.type === 'message_complete') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id ? { ...m, status: 'complete' } : m
|
||||
)
|
||||
);
|
||||
setMessages((prev) => {
|
||||
const completed = prev.find(
|
||||
(m): m is CoderMessage => m.id === frame.message_id && m.role === 'assistant',
|
||||
);
|
||||
const next = prev.map((m) =>
|
||||
m.id === frame.message_id && m.role !== 'tool'
|
||||
? { ...m, status: 'complete' as const }
|
||||
: m,
|
||||
);
|
||||
if (completed) {
|
||||
queueMicrotask(() => handlersRef.current.onAssistantComplete?.());
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else if (frame.type === 'tool_call') {
|
||||
const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> } | undefined;
|
||||
if (tc?.id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role !== 'assistant' || m.id !== frame.message_id
|
||||
? m
|
||||
: { ...m, tool_calls: mergeWireToolCall(m.tool_calls, { ...tc, args: tc.args ?? {} }) },
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (frame.type === 'tool_result') {
|
||||
setMessages((prev) => {
|
||||
const exists = prev.some((m) => m.id === frame.tool_message_id);
|
||||
if (exists) {
|
||||
return prev.map((m) =>
|
||||
m.role === 'tool' && m.id === frame.tool_message_id
|
||||
? {
|
||||
...m,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
}
|
||||
: m,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: frame.tool_message_id,
|
||||
role: 'tool' as const,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
} else if (frame.type === 'reasoning_delta') {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? {
|
||||
...m,
|
||||
tool_calls: [
|
||||
...(m.tool_calls ?? []),
|
||||
{ id: frame.tool_call_id, function: { name: frame.name, arguments: frame.arguments ?? '' } },
|
||||
],
|
||||
}
|
||||
: m
|
||||
)
|
||||
m.id === frame.message_id && m.role === 'assistant'
|
||||
? { ...m, reasoning_text: (m.reasoning_text ?? '') + (frame.content ?? '') }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} else if (frame.type === 'permission_requested') {
|
||||
handlersRef.current.onPermissionRequested?.({
|
||||
taskId: frame.task_id,
|
||||
toolTitle: frame.tool_title,
|
||||
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
|
||||
optionId: o.option_id,
|
||||
label: o.label,
|
||||
})),
|
||||
});
|
||||
} else if (frame.type === 'permission_resolved') {
|
||||
handlersRef.current.onPermissionResolved?.(frame.task_id);
|
||||
} else if (frame.type === 'agent_commands') {
|
||||
handlersRef.current.onAgentCommands?.(
|
||||
frame.task_id,
|
||||
(frame.commands ?? []).map((c: { name: string; description?: string }) => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -122,7 +318,11 @@ function useCoderMessages(sessionId: string) {
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return { messages, setMessages, connected };
|
||||
useEffect(() => {
|
||||
handlersRef.current.onConnectedChange?.(connected);
|
||||
}, [connected]);
|
||||
|
||||
return { messages, setMessages, connected, loadMessages };
|
||||
}
|
||||
|
||||
function usePendingChanges(sessionId: string) {
|
||||
@@ -165,48 +365,6 @@ function usePendingChanges(sessionId: string) {
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CoderMessageBubble({ message }: { message: CoderMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1 px-3 py-2', isUser ? 'items-end' : 'items-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 max-w-[85%] text-sm',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownRenderer content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||
<div className="mt-2 border-t border-border/50 pt-2 space-y-1">
|
||||
{message.tool_calls.map((tc) => (
|
||||
<div key={tc.id} className="text-xs font-mono text-muted-foreground">
|
||||
<span className="text-primary/70">{tc.function.name}</span>
|
||||
{tc.function.arguments && (
|
||||
<span className="ml-1 opacity-60">
|
||||
({tc.function.arguments.slice(0, 80)}
|
||||
{tc.function.arguments.length > 80 ? '...' : ''})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.status === 'streaming' && (
|
||||
<span className="inline-block w-2 h-4 bg-current opacity-60 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffPanel({
|
||||
changes,
|
||||
loading,
|
||||
@@ -296,115 +454,272 @@ function DiffPanel({
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CoderPane({ sessionId }: Props) {
|
||||
const { messages, setMessages, connected } = useCoderMessages(sessionId);
|
||||
export function CoderPane({
|
||||
sessionId,
|
||||
paneId,
|
||||
chatId,
|
||||
chatPending = false,
|
||||
projectPath,
|
||||
onConnectedChange,
|
||||
}: Props) {
|
||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||
provider: 'boocode',
|
||||
model: '',
|
||||
modeId: null,
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||
const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
|
||||
const [liveTaskCommands, setLiveTaskCommands] = useState<AgentCommand[]>([]);
|
||||
const { skills } = useSkills();
|
||||
const [slashState, setSlashState] = useState<{ query: string } | null>(null);
|
||||
|
||||
const displayedCommands = useMemo(() => {
|
||||
const base =
|
||||
agentConfig.provider === 'boocode'
|
||||
? skills.map((s) => ({ name: s.name, description: s.description }))
|
||||
: providerCommands;
|
||||
return mergeCommandsByName(base, liveTaskCommands);
|
||||
}, [agentConfig.provider, skills, providerCommands, liveTaskCommands]);
|
||||
|
||||
const skillsByName = useMemo(() => new Set(skills.map((s) => s.name)), [skills]);
|
||||
const commandsByName = useMemo(
|
||||
() => new Set(displayedCommands.map((c) => c.name)),
|
||||
[displayedCommands],
|
||||
);
|
||||
|
||||
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||
onConnectedChange,
|
||||
onPermissionRequested: (prompt) => {
|
||||
setActiveTaskId(prompt.taskId);
|
||||
setPermissionPrompt(prompt);
|
||||
},
|
||||
onPermissionResolved: (taskId) => {
|
||||
if (activeTaskId === taskId || permissionPrompt?.taskId === taskId) {
|
||||
setPermissionPrompt(null);
|
||||
}
|
||||
},
|
||||
onAssistantComplete: () => {
|
||||
setActiveTaskId(null);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
},
|
||||
onAgentCommands: (_taskId, commands) => {
|
||||
setLiveTaskCommands(commands);
|
||||
},
|
||||
});
|
||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [provider, setProvider] = useState('boocode');
|
||||
const [model, setModel] = useState('qwen3.6-35b-a3b-mxfp4');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Refresh pending changes when a message_complete arrives
|
||||
useEffect(() => {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role === 'assistant' && lastMsg.status === 'complete') {
|
||||
const lastAssistant = [...messages].reverse().find(
|
||||
(m): m is CoderMessage => m.role === 'assistant',
|
||||
);
|
||||
if (lastAssistant?.status === 'complete') {
|
||||
refresh();
|
||||
}
|
||||
}, [messages, refresh]);
|
||||
|
||||
// Poll fallbacks when WS is disconnected (reconnect uses WS as source of truth)
|
||||
useEffect(() => {
|
||||
if (!activeTaskId || connected) return;
|
||||
const interval = setInterval(() => {
|
||||
if (!permissionPrompt) {
|
||||
void api.coder
|
||||
.getTaskPermission(activeTaskId)
|
||||
.then((prompt) => {
|
||||
setPermissionPrompt({
|
||||
taskId: prompt.taskId,
|
||||
toolTitle: prompt.toolTitle,
|
||||
options: prompt.options,
|
||||
});
|
||||
})
|
||||
.catch(() => {/* no pending permission */});
|
||||
}
|
||||
void api.coder
|
||||
.getTaskCommands(activeTaskId)
|
||||
.then((res) => setLiveTaskCommands(res.commands))
|
||||
.catch(() => {/* not cached yet */});
|
||||
void api.coder
|
||||
.getTask(activeTaskId)
|
||||
.then((task) => {
|
||||
if (task.state === 'running' || task.state === 'pending' || task.state === 'blocked') {
|
||||
return;
|
||||
}
|
||||
setActiveTaskId(null);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
void loadMessages();
|
||||
})
|
||||
.catch(() => {/* task gone */});
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeTaskId, connected, permissionPrompt, loadMessages]);
|
||||
|
||||
const handleProviderCommandsChange = useCallback((commands: AgentCommand[]) => {
|
||||
setProviderCommands(commands);
|
||||
}, []);
|
||||
|
||||
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
|
||||
if (!permissionPrompt) return;
|
||||
setPermissionBusy(true);
|
||||
try {
|
||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
|
||||
setPermissionPrompt(null);
|
||||
} finally {
|
||||
setPermissionBusy(false);
|
||||
}
|
||||
}, [permissionPrompt]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
if (!text || sending || !chatId) return;
|
||||
|
||||
if (text.startsWith('/')) {
|
||||
const parsed = parseSlashInput(text);
|
||||
if (parsed) {
|
||||
const { cmdName, args } = parsed;
|
||||
if (agentConfig.provider === 'boocode' && skillsByName.has(cmdName)) {
|
||||
setInput('');
|
||||
setSlashState(null);
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
try {
|
||||
await api.coder.skillInvoke(
|
||||
sessionId,
|
||||
paneId,
|
||||
cmdName,
|
||||
args.length > 0 ? args : null,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!commandsByName.has(cmdName)) {
|
||||
// Unknown slash — fall through and send as literal text.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInput('');
|
||||
setSlashState(null);
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
|
||||
// Optimistic user message
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
provider: provider !== 'boocode' ? provider : undefined,
|
||||
model: model || undefined,
|
||||
}),
|
||||
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 (res.ok) {
|
||||
const data = await res.json();
|
||||
// Replace temp message with real one if server returned it
|
||||
if (data.user_message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => m.id === tempId ? { ...m, id: data.user_message_id } : m)
|
||||
);
|
||||
}
|
||||
if (data.user_message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// The WS will bring the real messages; optimistic is good enough
|
||||
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);
|
||||
}
|
||||
}, [input, sending, sessionId, provider, model, setMessages]);
|
||||
}, [
|
||||
input,
|
||||
sending,
|
||||
sessionId,
|
||||
paneId,
|
||||
chatId,
|
||||
agentConfig,
|
||||
skillsByName,
|
||||
commandsByName,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
const handleSlashSelect = useCallback((name: string) => {
|
||||
const next = `/${name} `;
|
||||
setInput(next);
|
||||
setSlashState(null);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = inputRef.current;
|
||||
if (ta) {
|
||||
ta.selectionStart = ta.selectionEnd = next.length;
|
||||
ta.focus();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const 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]
|
||||
[handleSend, slashState]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0">
|
||||
<Code size={14} className="text-muted-foreground shrink-0" />
|
||||
<ProviderPicker
|
||||
provider={provider}
|
||||
model={model}
|
||||
onChange={(prov, mod) => {
|
||||
setProvider(prov);
|
||||
setModel(mod);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full ml-auto shrink-0',
|
||||
connected ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-sm text-muted-foreground gap-2">
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-sm text-muted-foreground gap-2">
|
||||
<Code size={32} className="opacity-40" />
|
||||
<p>Send a message to start coding</p>
|
||||
<p>{chatPending || !chatId ? 'Preparing pane chat…' : 'Send a message to start coding'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{messages.map((msg) => (
|
||||
<CoderMessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<CoderMessageList
|
||||
messages={messages as CoderTimelineWire[]}
|
||||
footer={
|
||||
activeTaskId && !permissionPrompt && sending === false ? (
|
||||
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{permissionPrompt && (
|
||||
<PermissionCard
|
||||
prompt={permissionPrompt}
|
||||
onRespond={(id) => void handlePermissionRespond(id)}
|
||||
busy={permissionBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Diff panel — only shows when there are pending changes */}
|
||||
{changes.filter((c) => c.status === 'pending').length > 0 && (
|
||||
<div className="h-48 shrink-0">
|
||||
@@ -418,28 +733,46 @@ export function CoderPane({ sessionId }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 border-t border-border p-2">
|
||||
{/* Composer + input */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
|
||||
<AgentComposerBar
|
||||
projectPath={projectPath}
|
||||
value={agentConfig}
|
||||
onChange={setAgentConfig}
|
||||
onProviderCommandsChange={handleProviderCommandsChange}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask BooCoder to write code..."
|
||||
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}
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user