v1.4-fork-header: fork from message + delete message + header polish + housekeeping
- Fork: POST /api/chats/:id/fork creates a new chat in the same session, copies messages up to target (status=complete) with row-offset clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane event; Workspace opens it in the active pane. No maybeAutoNameChat on forks. - Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is currently streaming. Cascading-forward delete (created_at >= target). MessageBubble Trash button + confirm Dialog. - Header: Projects -> Project -> Session breadcrumb, model badge pill, inline session rename, active file path via new useActivePane() hook. Server now publishes session_renamed on PATCH /api/sessions/:id; client-side dup emit removed from Session.tsx. - Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill INSERT removed (CREATE TABLE retained), Tailnet trust comment near app.listen(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,22 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat, Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||
@@ -198,6 +207,9 @@ function ActionRow({
|
||||
}) {
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [forking, setForking] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
@@ -221,33 +233,114 @@ function ActionRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function fork() {
|
||||
if (forking || message.status !== 'complete') return;
|
||||
setForking(true);
|
||||
try {
|
||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||
} finally {
|
||||
setForking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.messages.remove(message.chat_id, message.id);
|
||||
setDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'delete failed');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const canRegen = isAssistant && message.status !== 'streaming';
|
||||
const canFork = message.status === 'complete';
|
||||
const canDelete = message.status !== 'streaming';
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Copy message"
|
||||
title="Copy"
|
||||
>
|
||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
{isAssistant && (
|
||||
<>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void regenerate()}
|
||||
disabled={!canRegen || regenerating}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="Regenerate message"
|
||||
title="Regenerate"
|
||||
onClick={() => void copy()}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Copy message"
|
||||
title="Copy"
|
||||
>
|
||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isAssistant && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void regenerate()}
|
||||
disabled={!canRegen || regenerating}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="Regenerate message"
|
||||
title="Regenerate"
|
||||
>
|
||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fork()}
|
||||
disabled={!canFork || forking}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="Fork from here"
|
||||
title="Fork from here"
|
||||
>
|
||||
<GitFork className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={!canDelete}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="Delete message"
|
||||
title="Delete message"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!deleting) setDeleteOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the selected message and every later message in this chat. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDelete()}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { DragEvent } from 'react';
|
||||
import { FolderOpen, MessageSquare, X } from 'lucide-react';
|
||||
import type { Pane, PaneKind } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
|
||||
interface Props {
|
||||
pane: Pane;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
onClose: () => void;
|
||||
onSplit: (kind: PaneKind) => void;
|
||||
onCloseOthers: () => void;
|
||||
onCloseToRight: () => void;
|
||||
onCloseAll: () => void;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
if (!path) return '';
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
function labelFor(pane: Pane): string {
|
||||
if (pane.kind === 'chat') return 'Chat';
|
||||
const openFile = pane.state.open_file;
|
||||
if (openFile) return basename(openFile);
|
||||
return 'Files';
|
||||
}
|
||||
|
||||
export function PaneTab({
|
||||
pane,
|
||||
isActive,
|
||||
onClick,
|
||||
onClose,
|
||||
onSplit,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onCloseAll,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}: Props) {
|
||||
const Icon = pane.kind === 'chat' ? MessageSquare : FolderOpen;
|
||||
const label = labelFor(pane);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none',
|
||||
isActive
|
||||
? 'bg-background text-foreground'
|
||||
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
|
||||
)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
<Icon size={12} className="shrink-0" />
|
||||
<span className="truncate max-w-[160px]" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="p-0.5 hover:bg-muted rounded opacity-60 hover:opacity-100 shrink-0"
|
||||
aria-label="Close tab"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Split</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onSelect={() => onSplit('chat')}>
|
||||
<MessageSquare /> Chat
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => onSplit('file_browser')}>
|
||||
<FolderOpen /> File Browser
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={onClose}>Close</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onCloseOthers}>Close others</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onCloseToRight}>
|
||||
Close to the right
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onCloseAll}>Close all</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||
import type { Chat, WorkspacePane } from '@/api/types';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
@@ -87,6 +88,29 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
savePanes(sessionId, panes);
|
||||
}, [sessionId, panes]);
|
||||
|
||||
useEffect(() => {
|
||||
const active = panes[activePaneIdx];
|
||||
if (!active) {
|
||||
clearActivePane();
|
||||
return;
|
||||
}
|
||||
setActivePaneInfo({
|
||||
sessionId,
|
||||
paneId: active.id,
|
||||
kind: active.kind,
|
||||
activeFile: null,
|
||||
});
|
||||
}, [sessionId, panes, activePaneIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearActivePane();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activePaneIdxRef = useRef(activePaneIdx);
|
||||
activePaneIdxRef.current = activePaneIdx;
|
||||
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
||||
@@ -118,6 +142,9 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
|
||||
removeChatFromPanes(event.chat_id);
|
||||
}
|
||||
if (event.type === 'open_chat_in_active_pane') {
|
||||
openChatInPane(activePaneIdxRef.current, event.chat_id);
|
||||
}
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Pane } from '@/api/types';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
pane: Pane;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PaneShell({ pane, onClose, className, children }: Props) {
|
||||
const label = pane.kind === 'chat' ? 'Chat' : 'Files';
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full min-h-0 border-r border-border last:border-r-0', className)}>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-0.5 hover:bg-muted rounded"
|
||||
aria-label="Close pane"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user