batch4: chats-in-sessions, force-send, /compact, right-rail file browser

Session 1:N Chat data model with backfill. Workspace switches to client-side
multi-tab pane management. Right-rail file browser with float-over viewer and
click-drag line selection replaces FileBrowserPane. Adds /compact streaming
summarizer (respects compact markers in context builder), force-send (cancels
in-flight, persists partial as 'cancelled', awaits cancellation completion via
deferred Promise + 5s timeout), message queue, stop generation, chat
auto-rename, session archive/unarchive with Closed Sessions section on repo
landing page. CHECK constraints on sessions.status, messages.role,
messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES /
MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the
api.panes.* client block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 20:39:48 +00:00
parent 6d9515b8a5
commit c35ec65fc4
37 changed files with 3290 additions and 1012 deletions

View File

@@ -0,0 +1,201 @@
import { useState } from 'react';
import { History, MessageSquare, Plus, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Props {
pane: WorkspacePane;
tabs: Chat[];
onSwitchTab: (tabIdx: number) => void;
onRemoveTab: (chatId: string) => void;
onNewChat: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onClose: (chatId: string) => Promise<void>;
onDelete: (chatId: string) => Promise<void>;
onRemovePane?: () => void;
}
export function ChatTabBar({
pane,
tabs,
onSwitchTab,
onRemoveTab,
onNewChat,
onShowHistory,
onRename,
onClose,
onDelete,
onRemovePane,
}: Props) {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
function startRename(chatId: string, currentName: string | null) {
setRenamingId(chatId);
setRenameValue(currentName ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
}
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
{/* Chat tabs */}
{tabs.map((chat, tabIdx) => {
const isActive = tabIdx === pane.activeChatIdx;
const label = chat.name ?? 'New chat';
return (
<ContextMenu key={chat.id}>
<ContextMenuTrigger asChild>
<div
onClick={() => onSwitchTab(tabIdx)}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
isActive
? 'bg-background text-foreground'
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
)}
>
<MessageSquare size={12} className="shrink-0" />
{renamingId === chat.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void finishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') void finishRename();
if (e.key === 'Escape') setRenamingId(null);
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-xs outline-none w-28"
/>
) : (
<span className="truncate max-w-[140px]" title={label}>
{label}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveTab(chat.id);
}}
className="p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0"
aria-label="Remove from tab bar"
>
<X size={10} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => void onClose(chat.id)}>
Close
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() => setDeleteConfirm(chat.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
{/* Empty state label */}
{tabs.length === 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<History size={12} className="shrink-0" />
<span>Session</span>
</div>
)}
{/* Action buttons */}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewChat}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="New chat"
title="New chat"
>
<Plus size={12} />
</button>
<button
type="button"
onClick={onShowHistory}
className={cn(
'p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
pane.kind === 'empty' && 'text-foreground bg-muted/50'
)}
aria-label="Session history"
title="Session history"
>
<History size={12} />
</button>
{onRemovePane && (
<button
type="button"
onClick={onRemovePane}
className="p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
)}
</div>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat</DialogTitle>
<DialogDescription>
This will permanently delete this chat 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 onDelete(deleteConfirm);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}