refactor: split FileBrowserPane / Workspace / runAssistantTurn

- FileBrowserPane.tsx: deleted (unreferenced post-v1.4 PaneTab.tsx removal;
  the legacy file_browser pane kind isn't part of the active WorkspacePane
  taxonomy).
- Workspace.tsx (524 -> 172 lines): extracted useWorkspacePanes(sessionId)
  and useSessionChats(sessionId) hooks. Workspace is layout-only composition
  now. localStorage key + WS frame handling + drag semantics unchanged.
- inference.ts runAssistantTurn (~265 -> 48 lines): bundled args into
  TurnArgs interface, extracted executeStreamPhase / executeToolPhase /
  finalizeCompletion / handleAbortOrError. All WS publish ordering preserved
  byte-for-byte (mentally traced for tool / non-tool / abort / error /
  depth-exceeded paths). flushPromise chain + setImmediate + signal
  propagation unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 04:35:20 +00:00
parent 59fe6f0522
commit 9436a81b5f
5 changed files with 779 additions and 1421 deletions

View File

@@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
export interface UseSessionChatsOpts {
removeChatFromPanes: (chatId: string) => void;
openChatInPane: (paneIdx: number, chatId: string) => void;
// Thin wrapper around openChatInPane(activePaneIdxRef.current, chatId);
// built by Workspace and passed in so this hook doesn't need to know
// about pane indexing.
openChatInActivePane: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
}
export interface UseSessionChatsResult {
chats: Chat[];
setChats: React.Dispatch<React.SetStateAction<Chat[]>>;
createChat: (paneIdx: number) => Promise<void>;
archiveChat: (chatId: string) => Promise<void>;
unarchiveChat: (chatId: string) => Promise<void>;
deleteChat: (chatId: string) => Promise<void>;
renameChat: (chatId: string, name: string) => Promise<void>;
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
}
export function useSessionChats(
sessionId: string,
opts: UseSessionChatsOpts,
): UseSessionChatsResult {
const [chats, setChats] = useState<Chat[]>([]);
const chatsRef = useRef<Chat[]>([]);
chatsRef.current = chats;
// Stable refs to opts callbacks so the subscription effect — which only
// re-runs on sessionId change — always sees the latest closures without
// unsubscribe/resubscribe churn.
const removeChatFromPanesRef = useRef(opts.removeChatFromPanes);
removeChatFromPanesRef.current = opts.removeChatFromPanes;
const openChatInPaneRef = useRef(opts.openChatInPane);
openChatInPaneRef.current = opts.openChatInPane;
const openChatInActivePaneRef = useRef(opts.openChatInActivePane);
openChatInActivePaneRef.current = opts.openChatInActivePane;
const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty);
initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty;
useEffect(() => {
let cancelled = false;
api.chats.listForSession(sessionId).then((list) => {
if (cancelled) return;
setChats(list);
const openChat = list.find((c) => c.status === 'open');
if (openChat) {
initializeFirstChatIfEmptyRef.current(openChat.id);
}
}).catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'chat_created' && event.session_id === sessionId) {
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) =>
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
));
}
if (event.type === 'chat_archived') {
setChats((prev) => prev.map((c) =>
c.id === event.chat_id ? { ...c, status: 'archived' as const } : c
));
removeChatFromPanesRef.current(event.chat_id);
}
if (event.type === 'chat_unarchived') {
setChats((prev) => {
if (prev.some((c) => c.id === event.chat.id)) {
return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c);
}
return [event.chat, ...prev];
});
}
if (event.type === 'chat_deleted') {
setChats((prev) => prev.filter((c) => c.id !== event.chat_id));
removeChatFromPanesRef.current(event.chat_id);
}
if (event.type === 'open_chat_in_active_pane') {
openChatInActivePaneRef.current(event.chat_id);
}
});
}, [sessionId]);
const createChat = useCallback(async (paneIdx: number) => {
try {
const chat = await api.chats.create(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];
});
openChatInPaneRef.current(paneIdx, chat.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
}
}, [sessionId]);
const archiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.archive(chatId);
// Server publishes chat_archived; bus forwarder updates state.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to archive chat');
}
}, []);
const unarchiveChat = useCallback(async (chatId: string) => {
try {
await api.chats.unarchive(chatId);
// Server publishes chat_unarchived.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to restore chat');
}
}, []);
const deleteChat = useCallback(async (chatId: string) => {
try {
await api.chats.remove(chatId);
setChats((prev) => prev.filter((c) => c.id !== chatId));
removeChatFromPanesRef.current(chatId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
}
}, []);
const renameChat = useCallback(async (chatId: string, name: string) => {
try {
await api.chats.update(chatId, { name });
setChats((prev) => prev.map((c) =>
c.id === chatId ? { ...c, name } : c
));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
}
}, []);
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => {
if (prev.some((c) => c.id === chat.id)) return prev;
return [chat, ...prev];
});
openChatInPaneRef.current(paneIdx, chat.id);
await api.messages.send(chat.id, content);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to send');
}
}, [sessionId]);
return {
chats,
setChats,
createChat,
archiveChat,
unarchiveChat,
deleteChat,
renameChat,
handleLandingSend,
};
}