batch4.1-5.1: dedup audit, archive 400 fix, sidebar Delete, landing-page enrichment, auto-name tool-call fix
- Fastify global empty-JSON-body parser fixes archive/unarchive/stop 400s - Removed redundant local sessionEvents.emit at all 5+2 sites with server-side WS publishers; added dedupe guards in useSidebar/Workspace/Project handlers - Sidebar session right-click adds Delete (destructive) with confirm Dialog - Session.tsx navigates away on session_deleted/session_archived for the active session - SessionLandingPage chat rows show message_count, effective_context_tokens, last_message_preview via LATERAL joins on GET /api/sessions/:id/chats - Workspace.tsx pane drag-to-reorder using native HTML5 events (no new deps) - CompactCard: Copy toast, Send-to-chat with target chat name, empty-state in share popover, Re-run button - auto_name.ts: filter count gate and assistant-fetch by content <> '' so tool-call assistant rows don't trip the once-and-only-once guard - Adds CLAUDE.md and apps/web/src/lib/format.ts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
@@ -61,6 +62,8 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const chatsRef = useRef<Chat[]>([]);
|
||||
chatsRef.current = chats;
|
||||
const draggingIdxRef = useRef<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -87,7 +90,10 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
||||
setChats((prev) => [event.chat, ...prev]);
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === event.chat.id)) return prev;
|
||||
return [event.chat, ...prev];
|
||||
});
|
||||
}
|
||||
if (event.type === 'chat_updated') {
|
||||
setChats((prev) => prev.map((c) =>
|
||||
@@ -177,8 +183,11 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
const createChat = useCallback(async (paneIdx: number) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
setChats((prev) => [chat, ...prev]);
|
||||
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
|
||||
// Optimistic local insert; the WS chat_created echo will be deduped by id.
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||
return [chat, ...prev];
|
||||
});
|
||||
openChatInPane(paneIdx, chat.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
||||
@@ -256,11 +265,61 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePaneDragStart = useCallback(
|
||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
draggingIdxRef.current = idx;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(idx));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePaneDragOver = useCallback(
|
||||
(idx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
if (draggingIdxRef.current === null) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverIdx !== idx) setDragOverIdx(idx);
|
||||
},
|
||||
[dragOverIdx]
|
||||
);
|
||||
|
||||
const handlePaneDragLeave = useCallback(() => {
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
const handlePaneDrop = useCallback(
|
||||
(targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const fromIdx = draggingIdxRef.current;
|
||||
draggingIdxRef.current = null;
|
||||
setDragOverIdx(null);
|
||||
if (fromIdx === null || fromIdx === targetIdx) return;
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
if (!moved) return prev;
|
||||
next.splice(targetIdx, 0, moved);
|
||||
// Keep active selection on the same logical pane (the one being dragged).
|
||||
setActivePaneIdx(targetIdx);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePaneDragEnd = useCallback(() => {
|
||||
draggingIdxRef.current = null;
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
|
||||
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
setChats((prev) => [chat, ...prev]);
|
||||
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
|
||||
setChats((prev) => {
|
||||
if (prev.some((c) => c.id === chat.id)) return prev;
|
||||
return [chat, ...prev];
|
||||
});
|
||||
openChatInPane(paneIdx, chat.id);
|
||||
await api.messages.send(chat.id, content);
|
||||
} catch (err) {
|
||||
@@ -315,23 +374,34 @@ export function Workspace({ sessionId, projectId }: Props) {
|
||||
<div
|
||||
key={pane.id}
|
||||
className={cn(
|
||||
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0',
|
||||
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20'
|
||||
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0 relative',
|
||||
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20',
|
||||
dragOverIdx === idx && draggingIdxRef.current !== idx &&
|
||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||
)}
|
||||
onClick={() => setActivePaneIdx(idx)}
|
||||
onDragOver={panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
>
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onNewChat={() => void createChat(idx)}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onClose={closeChat}
|
||||
onDelete={deleteChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
<div
|
||||
draggable={panes.length > 1}
|
||||
onDragStart={panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
>
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onNewChat={() => void createChat(idx)}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onClose={closeChat}
|
||||
onDelete={deleteChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{pane.kind === 'chat' && pane.chatId ? (
|
||||
|
||||
Reference in New Issue
Block a user