Add top_p/top_k/min_p/presence_penalty to AGENTS.md frontmatter and thread through inference (agents.ts parser → Agent type → stream-phase → sentinel summaries). Null means omit from request body, preserving provider defaults. Wire ask_user_input interactive card into both BooCoder frontends: the CoderPane in BooChat's SPA (CoderMessageList now renders AskUserInputCard instead of ToolCallLine for ask_user_input tool calls) and the standalone coder SPA (MessageBubble + new AskUserInputCard + shadcn ui primitives). Additional fixes: SessionLandingPage uses ChatInput with slash-command support and lazy chat creation; Session.tsx hydrate-race fix for empty pane promotion; AgentPicker wider dropdown with line-clamp; ModelPicker min-width; Textarea converted to forwardRef; Recon agent added to AGENTS.md; codecontext host port exposed in docker-compose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import type { Chat } from '@/api/types';
|
|
import { api } from '@/api/client';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ChatInput } from '@/components/ChatInput';
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { formatTokens } from '@/lib/format';
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
projectId: string;
|
|
chats: Chat[];
|
|
onOpenChat: (chatId: string) => void;
|
|
onSend: (content: string) => void;
|
|
/** Create a chat and return its id. Used by slash-command handler. */
|
|
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({
|
|
chats,
|
|
onOpenChat,
|
|
onSend,
|
|
projectId,
|
|
createChat,
|
|
onReopenChat,
|
|
onArchiveChat,
|
|
onRenameChat,
|
|
onDeleteChat,
|
|
}: Props) {
|
|
const [composerValue, setComposerValue] = useState('');
|
|
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> => {
|
|
if (chatId) return chatId;
|
|
try {
|
|
const chat = await createChat();
|
|
setChatId(chat.id);
|
|
return chat.id;
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
|
throw err;
|
|
}
|
|
}, [chatId, createChat]);
|
|
|
|
async function handleSend() {
|
|
const text = composerValue.trim();
|
|
if (!text) return;
|
|
try {
|
|
const cid = await ensureChat();
|
|
onSend(text);
|
|
setComposerValue('');
|
|
} catch {
|
|
// Error already surfaced via toast.
|
|
}
|
|
}
|
|
|
|
// v2.3: slash-command dispatch on landing page. Creates a chat first if
|
|
// one doesn't exist, then invokes the skill on that chat.
|
|
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
|
try {
|
|
const cid = await ensureChat();
|
|
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
|
setComposerValue('');
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
|
}
|
|
}, [ensureChat]);
|
|
|
|
function startRename(chat: Chat) {
|
|
setRenamingId(chat.id);
|
|
setRenameValue(chat.name ?? '');
|
|
}
|
|
|
|
async function finishRename() {
|
|
if (renamingId && renameValue.trim()) {
|
|
await onRenameChat(renamingId, renameValue.trim());
|
|
}
|
|
setRenamingId(null);
|
|
}
|
|
|
|
// TODO: Landing page chat counts are a snapshot at mount. New messages in
|
|
// visible chats won't update the per-row stats until next mount/navigation.
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
|
{openChats.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
|
|
<ul className="divide-y rounded-md border">
|
|
{openChats.map((chat) => (
|
|
<li key={chat.id}>
|
|
<ChatRow
|
|
chat={chat}
|
|
onClick={() => onOpenChat(chat.id)}
|
|
renamingId={renamingId}
|
|
renameValue={renameValue}
|
|
setRenameValue={setRenameValue}
|
|
onFinishRename={() => void finishRename()}
|
|
onCancelRename={() => setRenamingId(null)}
|
|
onContextStartRename={() => startRename(chat)}
|
|
onContextArchive={() => setArchiveConfirm(chat)}
|
|
onContextDelete={() => setDeleteConfirm(chat)}
|
|
showContextMenu
|
|
actions={
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Archive chat"
|
|
title="Archive chat"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setArchiveConfirm(chat);
|
|
}}
|
|
>
|
|
<Archive size={14} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Delete chat"
|
|
title="Delete chat"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDeleteConfirm(chat);
|
|
}}
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{archivedChats.length > 0 && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowArchived(!showArchived)}
|
|
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
|
|
>
|
|
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
Archived chats ({archivedChats.length})
|
|
</button>
|
|
{showArchived && (
|
|
<ul className="divide-y rounded-md border">
|
|
{archivedChats.map((chat) => (
|
|
<li key={chat.id}>
|
|
<ChatRow
|
|
chat={chat}
|
|
onClick={() => void onReopenChat(chat.id)}
|
|
dimmed
|
|
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
|
|
renamingId={null}
|
|
renameValue=""
|
|
setRenameValue={() => {}}
|
|
onFinishRename={() => {}}
|
|
onCancelRename={() => {}}
|
|
onContextStartRename={() => {}}
|
|
onContextArchive={() => {}}
|
|
onContextDelete={() => {}}
|
|
showContextMenu={false}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{openChats.length === 0 && archivedChats.length === 0 && (
|
|
<div className="text-sm text-muted-foreground py-8 text-center">
|
|
No chats yet. Type below to start a conversation.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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
|
|
projectId={projectId}
|
|
onSend={handleSend}
|
|
onSlashCommand={handleSlashCommand}
|
|
chatId={chatId ?? undefined}
|
|
chatLabel={chatId ? undefined : 'Chat'}
|
|
disabled={false}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|