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:
201
apps/web/src/components/ChatTabBar.tsx
Normal file
201
apps/web/src/components/ChatTabBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user