diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts index d706fe3..cdc652f 100644 --- a/apps/server/src/services/inference.ts +++ b/apps/server/src/services/inference.ts @@ -348,39 +348,27 @@ async function executeToolCall( } } -async function runAssistantTurn( - ctx: InferenceContext, - sessionId: string, - chatId: string, - assistantMessageId: string, - depth: number, - signal?: AbortSignal -): Promise { - if (depth > MAX_TOOL_LOOP_DEPTH) { - await ctx.sql` - UPDATE messages - SET status = 'failed', - content = ${'tool loop depth exceeded'}, - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - `; - ctx.publish(sessionId, { - type: 'error', - message_id: assistantMessageId, - chat_id: chatId, - error: 'tool loop depth exceeded', - }); - return; - } +interface TurnArgs { + sessionId: string; + chatId: string; + assistantMessageId: string; + depth: number; + signal: AbortSignal | undefined; +} - const loaded = await loadContext(ctx.sql, sessionId, chatId); - if (!loaded) { - ctx.log.warn({ sessionId }, 'inference: session or project missing'); - return; - } - const { session, project, history } = loaded; - const projectRoot = await resolveProjectRoot(project.path); - const messages = buildMessagesPayload(session, project, history); +interface StreamPhaseState { + accumulated: string; + startedAt: string | null; +} + +async function executeStreamPhase( + ctx: InferenceContext, + args: TurnArgs, + session: Session, + messages: OpenAiMessage[], + state: StreamPhaseState +): Promise { + const { sessionId, chatId, assistantMessageId, signal } = args; const startedRow = await ctx.sql<{ started_at: string }[]>` UPDATE messages @@ -388,7 +376,7 @@ async function runAssistantTurn( WHERE id = ${assistantMessageId} RETURNING started_at `; - const startedAt = startedRow[0]?.started_at ?? null; + state.startedAt = startedRow[0]?.started_at ?? null; ctx.publish(sessionId, { type: 'message_started', @@ -397,7 +385,6 @@ async function runAssistantTurn( role: 'assistant', }); - let accumulated = ''; let pendingFlushTimer: NodeJS.Timeout | null = null; let flushPromise: Promise = Promise.resolve(); @@ -406,7 +393,7 @@ async function runAssistantTurn( clearTimeout(pendingFlushTimer); pendingFlushTimer = null; } - const snapshot = accumulated; + const snapshot = state.accumulated; flushPromise = flushPromise.then(() => ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}` ); @@ -420,15 +407,14 @@ async function runAssistantTurn( }, DB_FLUSH_INTERVAL_MS); }; - let result: StreamResult; try { - result = await streamCompletion( + return await streamCompletion( ctx, session.model, messages, true, (delta) => { - accumulated += delta; + state.accumulated += delta; ctx.publish(sessionId, { type: 'delta', message_id: assistantMessageId, @@ -440,136 +426,162 @@ async function runAssistantTurn( }, signal ); - } catch (err) { + } finally { if (pendingFlushTimer) { clearTimeout(pendingFlushTimer); pendingFlushTimer = null; } await flushPromise; - const isAbort = err instanceof Error && err.name === 'AbortError'; - const finalStatus = isAbort ? 'cancelled' : 'failed'; - await ctx.sql` - UPDATE messages - SET status = ${finalStatus}, - content = ${accumulated}, - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - `; - const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>` - UPDATE sessions SET updated_at = clock_timestamp() - WHERE id = ${sessionId} - RETURNING project_id, name, updated_at - `; - ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at }); - if (isAbort) { - ctx.publish(sessionId, { - type: 'message_complete', - message_id: assistantMessageId, - chat_id: chatId, - }); - ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled'); - } else { - const errMsg = err instanceof Error ? err.message : String(err); - ctx.publish(sessionId, { - type: 'error', - message_id: assistantMessageId, - chat_id: chatId, - error: errMsg, - }); - ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed'); - } - return; } +} - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - await flushPromise; - - const { content, finishReason, toolCalls, promptTokens, completionTokens, nCtx } = result; - - if (toolCalls.length > 0) { - const [updated] = await ctx.sql< - { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] - >` - UPDATE messages - SET content = ${content}, - status = 'complete', - tool_calls = ${ctx.sql.json(toolCalls as never)}, - tokens_used = ${completionTokens}, - ctx_used = ${promptTokens}, - ctx_max = ${nCtx}, - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - RETURNING tokens_used, ctx_used, ctx_max, finished_at - `; - const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>` - UPDATE sessions SET updated_at = clock_timestamp() - WHERE id = ${sessionId} - RETURNING project_id, name, updated_at - `; - ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at }); - for (const tc of toolCalls) { - ctx.publish(sessionId, { - type: 'tool_call', - message_id: assistantMessageId, - chat_id: chatId, - tool_call: tc, - }); - } +async function handleAbortOrError( + ctx: InferenceContext, + args: TurnArgs, + accumulated: string, + err: unknown +): Promise { + const { sessionId, chatId, assistantMessageId } = args; + const isAbort = err instanceof Error && err.name === 'AbortError'; + const finalStatus = isAbort ? 'cancelled' : 'failed'; + await ctx.sql` + UPDATE messages + SET status = ${finalStatus}, + content = ${accumulated}, + finished_at = clock_timestamp() + WHERE id = ${assistantMessageId} + `; + const [failSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>` + UPDATE sessions SET updated_at = clock_timestamp() + WHERE id = ${sessionId} + RETURNING project_id, name, updated_at + `; + ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: failSessRow!.project_id, name: failSessRow!.name, updated_at: failSessRow!.updated_at }); + if (isAbort) { ctx.publish(sessionId, { type: 'message_complete', message_id: assistantMessageId, chat_id: chatId, - tokens_used: updated?.tokens_used ?? null, - ctx_used: updated?.ctx_used ?? null, - ctx_max: updated?.ctx_max ?? null, - started_at: startedAt, - finished_at: updated?.finished_at ?? null, - model: session.model, }); - - await Promise.all( - toolCalls.map(async (tc) => { - const [toolRow] = await ctx.sql<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at) - VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp()) - RETURNING id - `; - const toolMessageId = toolRow!.id; - const tres = await executeToolCall(projectRoot, tc); - const stored = { - tool_call_id: tc.id, - output: tres.output, - truncated: tres.truncated, - ...(tres.error ? { error: tres.error } : {}), - }; - await ctx.sql` - UPDATE messages - SET tool_results = ${ctx.sql.json(stored as never)} - WHERE id = ${toolMessageId} - `; - ctx.publish(sessionId, { - type: 'tool_result', - tool_message_id: toolMessageId, - chat_id: chatId, - tool_call_id: tc.id, - output: tres.output, - truncated: tres.truncated, - ...(tres.error ? { error: tres.error } : {}), - }); - }) - ); - - const [nextAssistant] = await ctx.sql<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at) - VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp()) - RETURNING id - `; - await runAssistantTurn(ctx, sessionId, chatId, nextAssistant!.id, depth + 1, signal); - return; + ctx.log.info({ sessionId, chatId, assistantMessageId }, 'inference cancelled'); + } else { + const errMsg = err instanceof Error ? err.message : String(err); + ctx.publish(sessionId, { + type: 'error', + message_id: assistantMessageId, + chat_id: chatId, + error: errMsg, + }); + ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed'); } +} + +async function executeToolPhase( + ctx: InferenceContext, + args: TurnArgs, + result: StreamResult, + startedAt: string | null, + session: Session, + projectRoot: string +): Promise { + const { sessionId, chatId, assistantMessageId, depth, signal } = args; + const { content, toolCalls, promptTokens, completionTokens, nCtx } = result; + + const [updated] = await ctx.sql< + { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] + >` + UPDATE messages + SET content = ${content}, + status = 'complete', + tool_calls = ${ctx.sql.json(toolCalls as never)}, + tokens_used = ${completionTokens}, + ctx_used = ${promptTokens}, + ctx_max = ${nCtx}, + finished_at = clock_timestamp() + WHERE id = ${assistantMessageId} + RETURNING tokens_used, ctx_used, ctx_max, finished_at + `; + const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>` + UPDATE sessions SET updated_at = clock_timestamp() + WHERE id = ${sessionId} + RETURNING project_id, name, updated_at + `; + ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at }); + for (const tc of toolCalls) { + ctx.publish(sessionId, { + type: 'tool_call', + message_id: assistantMessageId, + chat_id: chatId, + tool_call: tc, + }); + } + ctx.publish(sessionId, { + type: 'message_complete', + message_id: assistantMessageId, + chat_id: chatId, + tokens_used: updated?.tokens_used ?? null, + ctx_used: updated?.ctx_used ?? null, + ctx_max: updated?.ctx_max ?? null, + started_at: startedAt, + finished_at: updated?.finished_at ?? null, + model: session.model, + }); + + await Promise.all( + toolCalls.map(async (tc) => { + const [toolRow] = await ctx.sql<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status, created_at) + VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp()) + RETURNING id + `; + const toolMessageId = toolRow!.id; + const tres = await executeToolCall(projectRoot, tc); + const stored = { + tool_call_id: tc.id, + output: tres.output, + truncated: tres.truncated, + ...(tres.error ? { error: tres.error } : {}), + }; + await ctx.sql` + UPDATE messages + SET tool_results = ${ctx.sql.json(stored as never)} + WHERE id = ${toolMessageId} + `; + ctx.publish(sessionId, { + type: 'tool_result', + tool_message_id: toolMessageId, + chat_id: chatId, + tool_call_id: tc.id, + output: tres.output, + truncated: tres.truncated, + ...(tres.error ? { error: tres.error } : {}), + }); + }) + ); + + const [nextAssistant] = await ctx.sql<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status, created_at) + VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp()) + RETURNING id + `; + await runAssistantTurn(ctx, { + sessionId, + chatId, + assistantMessageId: nextAssistant!.id, + depth: depth + 1, + signal, + }); +} + +async function finalizeCompletion( + ctx: InferenceContext, + args: TurnArgs, + result: StreamResult, + startedAt: string | null, + session: Session +): Promise { + const { sessionId, chatId, assistantMessageId } = args; + const { content, finishReason, promptTokens, completionTokens, nCtx } = result; const [updated] = await ctx.sql< { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] @@ -615,6 +627,55 @@ async function runAssistantTurn( ); } +async function runAssistantTurn( + ctx: InferenceContext, + args: TurnArgs, +): Promise { + const { sessionId, chatId, assistantMessageId, depth } = args; + + if (depth > MAX_TOOL_LOOP_DEPTH) { + await ctx.sql` + UPDATE messages + SET status = 'failed', + content = ${'tool loop depth exceeded'}, + finished_at = clock_timestamp() + WHERE id = ${assistantMessageId} + `; + ctx.publish(sessionId, { + type: 'error', + message_id: assistantMessageId, + chat_id: chatId, + error: 'tool loop depth exceeded', + }); + return; + } + + const loaded = await loadContext(ctx.sql, sessionId, chatId); + if (!loaded) { + ctx.log.warn({ sessionId }, 'inference: session or project missing'); + return; + } + const { session, project, history } = loaded; + const projectRoot = await resolveProjectRoot(project.path); + const messages = buildMessagesPayload(session, project, history); + + const state: StreamPhaseState = { accumulated: '', startedAt: null }; + let result: StreamResult; + try { + result = await executeStreamPhase(ctx, args, session, messages, state); + } catch (err) { + await handleAbortOrError(ctx, args, state.accumulated, err); + return; + } + + if (result.toolCalls.length > 0) { + await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot); + return; + } + + await finalizeCompletion(ctx, args, result, state.startedAt, session); +} + export async function runInference( ctx: InferenceContext, sessionId: string, @@ -622,7 +683,7 @@ export async function runInference( assistantMessageId: string, signal?: AbortSignal ): Promise { - return runAssistantTurn(ctx, sessionId, chatId, assistantMessageId, 0, signal); + return runAssistantTurn(ctx, { sessionId, chatId, assistantMessageId, depth: 0, signal }); } const COMPACT_SYSTEM_PROMPT = diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index 87139c4..aaf4777 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -1,11 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { DragEvent } from 'react'; +import { useCallback } from 'react'; 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 { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes'; +import { useSessionChats } from '@/hooks/useSessionChats'; import { ChatPane } from '@/components/panes/ChatPane'; import { ChatTabBar } from '@/components/ChatTabBar'; import { SessionLandingPage } from '@/components/SessionLandingPage'; @@ -22,402 +19,53 @@ interface Props { projectId: string; } -const MAX_PANES = 5; -const STORAGE_KEY = 'boocode.workspace.panes'; - -function generateId(): string { - return crypto.randomUUID(); -} - -function emptyPane(): WorkspacePane { - return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 }; -} - -function chatPane(chatId: string): WorkspacePane { - return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; -} - -function loadPanes(sessionId: string): WorkspacePane[] | null { - try { - const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`); - if (!raw) return null; - const parsed = JSON.parse(raw) as WorkspacePane[]; - if (!Array.isArray(parsed) || parsed.length === 0) return null; - return parsed; - } catch { - return null; - } -} - -function savePanes(sessionId: string, panes: WorkspacePane[]): void { - try { - localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes)); - } catch { /* quota or disabled */ } -} - export function Workspace({ sessionId, projectId }: Props) { - const [panes, setPanes] = useState(() => { - return loadPanes(sessionId) ?? [emptyPane()]; + const { + panes, + activePaneIdx, + setActivePaneIdx, + activePaneIdxRef, + openChatInPane, + switchTab, + removeTab, + closeOtherTabs, + closeTabsToRight, + closeAllTabs, + showLandingPage, + addSplitPane, + removePane, + removeChatFromPanes, + initializeFirstChatIfEmpty, + handlePaneDragStart, + handlePaneDragOver, + handlePaneDragLeave, + handlePaneDrop, + handlePaneDragEnd, + dragOverIdx, + draggingIdxRef, + } = useWorkspacePanes(sessionId); + + // Thin wrapper so useSessionChats can route open_chat_in_active_pane events + // without knowing about pane indexing. + const openChatInActivePane = useCallback( + (chatId: string) => openChatInPane(activePaneIdxRef.current, chatId), + [openChatInPane, activePaneIdxRef], + ); + + const { + chats, + createChat, + archiveChat, + unarchiveChat, + deleteChat, + renameChat, + handleLandingSend, + } = useSessionChats(sessionId, { + removeChatFromPanes, + openChatInPane, + openChatInActivePane, + initializeFirstChatIfEmpty, }); - const [activePaneIdx, setActivePaneIdx] = useState(0); - const [chats, setChats] = useState([]); - const chatsRef = useRef([]); - chatsRef.current = chats; - const draggingIdxRef = useRef(null); - const [dragOverIdx, setDragOverIdx] = useState(null); - - 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) { - setPanes((prev) => { - if (prev.length === 1 && prev[0]!.kind === 'empty') { - return [chatPane(openChat.id)]; - } - return prev; - }); - } - }).catch(() => {}); - return () => { cancelled = true; }; - }, [sessionId]); - - useEffect(() => { - 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) { - 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 - )); - removeChatFromPanes(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)); - removeChatFromPanes(event.chat_id); - } - if (event.type === 'open_chat_in_active_pane') { - openChatInPane(activePaneIdxRef.current, event.chat_id); - } - }); - }, [sessionId]); - - function removeChatFromPanes(chatId: string) { - setPanes((prev) => prev.map((p) => { - const idx = p.chatIds.indexOf(chatId); - if (idx < 0) return p; - const nextIds = p.chatIds.filter((id) => id !== chatId); - if (nextIds.length === 0) { - return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; - } - const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1); - return { - ...p, - chatIds: nextIds, - activeChatIdx: nextActiveIdx, - chatId: nextIds[nextActiveIdx], - }; - })); - } - - const openChatInPane = useCallback((paneIdx: number, chatId: string) => { - setPanes((prev) => { - const next = [...prev]; - const pane = next[paneIdx]!; - const existing = pane.chatIds.indexOf(chatId); - if (existing >= 0) { - next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing }; - } else { - const newIds = [...pane.chatIds, chatId]; - next[paneIdx] = { - ...pane, - kind: 'chat', - chatId, - chatIds: newIds, - activeChatIdx: newIds.length - 1, - }; - } - return next; - }); - setActivePaneIdx(paneIdx); - }, []); - - const switchTab = useCallback((paneIdx: number, tabIdx: number) => { - setPanes((prev) => { - const next = [...prev]; - const pane = next[paneIdx]!; - const chatId = pane.chatIds[tabIdx]; - if (!chatId) return prev; - next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx }; - return next; - }); - }, []); - - const removeTab = useCallback((paneIdx: number, chatId: string) => { - setPanes((prev) => { - const next = [...prev]; - const pane = next[paneIdx]!; - const nextIds = pane.chatIds.filter((id) => id !== chatId); - if (nextIds.length === 0) { - next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; - } else { - const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); - next[paneIdx] = { - ...pane, - chatIds: nextIds, - activeChatIdx: nextActiveIdx, - chatId: nextIds[nextActiveIdx], - }; - } - return next; - }); - }, []); - - // Keep only the right-clicked tab open in this pane. - const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => { - setPanes((prev) => { - const next = [...prev]; - const pane = next[paneIdx]!; - const keepIdx = pane.chatIds.indexOf(keepChatId); - if (keepIdx < 0) return prev; - next[paneIdx] = { - ...pane, - kind: 'chat', - chatId: keepChatId, - chatIds: [keepChatId], - activeChatIdx: 0, - }; - return next; - }); - }, []); - - // Close every tab to the right of the right-clicked one. - const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => { - setPanes((prev) => { - const next = [...prev]; - const pane = next[paneIdx]!; - const pivotIdx = pane.chatIds.indexOf(pivotChatId); - if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev; - const nextIds = pane.chatIds.slice(0, pivotIdx + 1); - const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); - next[paneIdx] = { - ...pane, - chatIds: nextIds, - activeChatIdx: nextActiveIdx, - chatId: nextIds[nextActiveIdx], - }; - return next; - }); - }, []); - - // Close every tab in this pane; land on landing page. - const closeAllTabs = useCallback((paneIdx: number) => { - setPanes((prev) => { - const next = [...prev]; - const pane = next[paneIdx]!; - next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; - return next; - }); - }, []); - - 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]; - }); - openChatInPane(paneIdx, chat.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to create chat'); - } - }, [sessionId, openChatInPane]); - - 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)); - removeChatFromPanes(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 showLandingPage = useCallback((paneIdx: number) => { - setPanes((prev) => { - const next = [...prev]; - const pane = next[paneIdx]!; - next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; - return next; - }); - }, []); - - const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => { - if (kind === 'terminal') { - toast('Terminal panes coming in BooTerm'); - return; - } - if (kind === 'agent') { - toast('Agent panes coming in BooCoder'); - return; - } - setPanes((prev) => { - if (prev.length >= MAX_PANES) { - toast.error(`Maximum ${MAX_PANES} panes`); - return prev; - } - const next = [...prev, emptyPane()]; - setActivePaneIdx(next.length - 1); - return next; - }); - }, []); - - const removePane = useCallback((idx: number) => { - setPanes((prev) => { - if (prev.length <= 1) return prev; - const next = prev.filter((_, i) => i !== idx); - setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); - return next; - }); - }, []); - - const handlePaneDragStart = useCallback( - (idx: number) => (e: DragEvent) => { - draggingIdxRef.current = idx; - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', String(idx)); - }, - [] - ); - - const handlePaneDragOver = useCallback( - (idx: number) => (e: DragEvent) => { - 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) => { - 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) => { - 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) { - toast.error(err instanceof Error ? err.message : 'Failed to send'); - } - }, [sessionId, openChatInPane]); function chatsForPane(pane: WorkspacePane): Chat[] { return pane.chatIds diff --git a/apps/web/src/components/panes/FileBrowserPane.tsx b/apps/web/src/components/panes/FileBrowserPane.tsx deleted file mode 100644 index 9197e6d..0000000 --- a/apps/web/src/components/panes/FileBrowserPane.tsx +++ /dev/null @@ -1,865 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { KeyboardEvent } from 'react'; -import { Check, ChevronRight, ChevronDown, Copy, FileText, Folder, X } from 'lucide-react'; -import { codeToHtml } from 'shiki'; -import { api, ApiError } from '@/api/client'; -import type { - FileBrowserPaneState, - FileEntry, - Pane, - ViewFileResult, -} from '@/api/types'; -import { inferLanguage } from '@/lib/attachments'; -import { sessionEvents } from '@/hooks/sessionEvents'; -import { cn } from '@/lib/utils'; - -interface Props { - pane: Pane & { kind: 'file_browser' }; - projectId: string; - onStateChange: (state: FileBrowserPaneState) => void; -} - -const SHIKI_THEME = 'github-dark'; - -function splitShikiLines(html: string): string[] { - const match = html.match(/]*>([\s\S]*)<\/code>/); - if (!match) return []; - const inner = match[1]!; - const lines = inner.split(/(?=)/); - return lines.filter(l => l.trim().length > 0); -} - -interface FileViewerProps { - code: string; - lang: string | null; - selectedLines: Set; - onLineClick: (lineNo: number, shiftKey: boolean) => void; -} - -function FileViewer({ code, lang, selectedLines, onLineClick }: FileViewerProps) { - const [copied, setCopied] = useState(false); - const [lineHtmls, setLineHtmls] = useState(null); - - useEffect(() => { - let cancelled = false; - if (!lang) { - setLineHtmls(null); - return; - } - (async () => { - try { - const result = await codeToHtml(code, { lang, theme: SHIKI_THEME }); - if (cancelled) return; - const lines = splitShikiLines(result); - setLineHtmls(lines.length > 0 ? lines : null); - } catch (err) { - console.warn('shiki failed', err); - if (!cancelled) setLineHtmls(null); - } - })(); - return () => { - cancelled = true; - }; - }, [code, lang]); - - async function copy() { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - } catch { - /* ignore */ - } - } - - const plainLines = code.split('\n'); - const totalLines = lineHtmls ? lineHtmls.length : plainLines.length; - - return ( -
-
- {lang || 'code'} - -
-
- {Array.from({ length: totalLines }, (_, i) => { - const lineNo = i + 1; - const isSelected = selectedLines.has(lineNo); - return ( -
- - {lineHtmls ? ( -
- ) : ( - - {plainLines[i] ?? ''} - - )} -
- ); - })} -
-
- ); -} - -function basename(path: string): string { - if (!path) return ''; - const parts = path.split('/'); - return parts[parts.length - 1] ?? path; -} - -function joinPath(parent: string, name: string): string { - if (!parent || parent === '.' || parent === '') return name; - return `${parent}/${name}`; -} - -interface TreeNodeProps { - parentPath: string; // '' for root children - entries: FileEntry[]; - cache: Map; - expanded: Set; - openFile: string | null; - highlightedPath: string | null; - depth: number; - onToggleDir: (dirPath: string) => void; - onSelectFile: (path: string) => void; - setHighlightedPath: (p: string) => void; -} - -function TreeNode({ - parentPath, - entries, - cache, - expanded, - openFile, - highlightedPath, - depth, - onToggleDir, - onSelectFile, - setHighlightedPath, -}: TreeNodeProps) { - // Sort: dirs first, then files; alphabetical within each. - const sorted = useMemo(() => { - const copy = [...entries]; - copy.sort((a, b) => { - if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1; - return a.name.localeCompare(b.name); - }); - return copy; - }, [entries]); - - return ( -
    - {sorted.map((entry) => { - const fullPath = joinPath(parentPath, entry.name); - const isExpanded = entry.kind === 'dir' && expanded.has(fullPath); - const isActive = entry.kind === 'file' && openFile === fullPath; - const isHighlight = highlightedPath === fullPath; - return ( -
  • -
    { - setHighlightedPath(fullPath); - if (entry.kind === 'dir') { - onToggleDir(fullPath); - } else { - onSelectFile(fullPath); - } - }} - > - {entry.kind === 'dir' ? ( - - ) : ( - - )} - {entry.kind === 'dir' ? ( - - ) : ( - - )} - {entry.name} -
    - {entry.kind === 'dir' && isExpanded && cache.has(fullPath) && ( - - )} -
  • - ); - })} -
- ); -} - -export function FileBrowserPane({ pane, projectId, onStateChange }: Props) { - const openFile = pane.state.open_file ?? null; - const filter = pane.state.filter ?? ''; - const expandedDirs = useMemo( - () => pane.state.expanded_dirs ?? [], - [pane.state.expanded_dirs] - ); - - // Local filter (debounced 100ms before pushing to onStateChange) - const [filterDraft, setFilterDraft] = useState(filter); - const filterDebounceRef = useRef | null>(null); - - // Track previous external filter so we can sync local draft when the - // canonical state changes from outside (e.g. server snapshot, other tab). - const lastExternalFilter = useRef(filter); - useEffect(() => { - if (filter !== lastExternalFilter.current) { - lastExternalFilter.current = filter; - setFilterDraft(filter); - } - }, [filter]); - - function onFilterInput(value: string) { - setFilterDraft(value); - if (filterDebounceRef.current !== null) { - clearTimeout(filterDebounceRef.current); - } - filterDebounceRef.current = setTimeout(() => { - filterDebounceRef.current = null; - lastExternalFilter.current = value; - onStateChange({ - ...pane.state, - filter: value, - open_file: openFile, - expanded_dirs: expandedDirs, - }); - }, 100); - } - - useEffect(() => { - return () => { - if (filterDebounceRef.current !== null) { - clearTimeout(filterDebounceRef.current); - } - }; - }, []); - - // Full file list fetched once on mount for filter mode (covers unexpanded dirs) - const [fullFileList, setFullFileList] = useState(null); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - const result = await api.projects.files(projectId); - if (!cancelled) setFullFileList(result.files); - } catch { - // Silently ignore; filter will fall back to cache-based list - } - })(); - return () => { - cancelled = true; - }; - // Intentionally run once per mount (projectId is stable per pane) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); - - // Directory cache: dirPath -> entries - const [cache, setCache] = useState>(new Map()); - const [loadingDirs, setLoadingDirs] = useState>(new Set()); - const [dirErrors, setDirErrors] = useState>(new Map()); - - const loadDir = useCallback( - async (dirPath: string) => { - // dirPath '' is root; server expects '.' - const apiPath = dirPath === '' ? '.' : dirPath; - setLoadingDirs((prev) => { - if (prev.has(dirPath)) return prev; - const next = new Set(prev); - next.add(dirPath); - return next; - }); - try { - const result = await api.projects.listDir(projectId, apiPath); - setCache((prev) => { - const next = new Map(prev); - next.set(dirPath, result.entries); - return next; - }); - setDirErrors((prev) => { - if (!prev.has(dirPath)) return prev; - const next = new Map(prev); - next.delete(dirPath); - return next; - }); - } catch (err) { - const msg = err instanceof Error ? err.message : 'failed to list directory'; - setDirErrors((prev) => { - const next = new Map(prev); - next.set(dirPath, msg); - return next; - }); - } finally { - setLoadingDirs((prev) => { - if (!prev.has(dirPath)) return prev; - const next = new Set(prev); - next.delete(dirPath); - return next; - }); - } - }, - [projectId] - ); - - // Load root on mount + any expanded dirs from server state. - useEffect(() => { - if (!cache.has('')) { - void loadDir(''); - } - for (const dir of expandedDirs) { - if (!cache.has(dir)) { - void loadDir(dir); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); - - // When expandedDirs grows (e.g. user expands), ensure new dir is loaded. - useEffect(() => { - for (const dir of expandedDirs) { - if (!cache.has(dir) && !loadingDirs.has(dir)) { - void loadDir(dir); - } - } - }, [expandedDirs, cache, loadingDirs, loadDir]); - - const expandedSet = useMemo(() => new Set(expandedDirs), [expandedDirs]); - - function toggleDir(dirPath: string) { - let nextDirs: string[]; - if (expandedSet.has(dirPath)) { - nextDirs = expandedDirs.filter((d) => d !== dirPath); - } else { - nextDirs = [...expandedDirs, dirPath]; - } - onStateChange({ - ...pane.state, - open_file: openFile, - filter: filterDraft, - expanded_dirs: nextDirs, - }); - } - - function selectFile(path: string) { - onStateChange({ - ...pane.state, - open_file: path, - filter: filterDraft, - expanded_dirs: expandedDirs, - }); - } - - function closeOpenFile() { - onStateChange({ - ...pane.state, - open_file: null, - filter: filterDraft, - expanded_dirs: expandedDirs, - }); - } - - // Build a flat list of all entries reachable through the loaded cache, - // for filter results and keyboard navigation. - interface FlatEntry { - path: string; - name: string; - kind: 'file' | 'dir'; - } - - const flattenedVisible = useMemo(() => { - const result: FlatEntry[] = []; - function walk(dirPath: string) { - const entries = cache.get(dirPath); - if (!entries) return; - const sorted = [...entries].sort((a, b) => { - if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1; - return a.name.localeCompare(b.name); - }); - for (const e of sorted) { - const full = joinPath(dirPath, e.name); - result.push({ path: full, name: e.name, kind: e.kind }); - if (e.kind === 'dir' && expandedSet.has(full)) { - walk(full); - } - } - } - walk(''); - return result; - }, [cache, expandedSet]); - - const flattenedAll = useMemo(() => { - const result: FlatEntry[] = []; - function walk(dirPath: string) { - const entries = cache.get(dirPath); - if (!entries) return; - for (const e of entries) { - const full = joinPath(dirPath, e.name); - result.push({ path: full, name: e.name, kind: e.kind }); - if (e.kind === 'dir') walk(full); - } - } - walk(''); - return result; - }, [cache]); - - const trimmedFilter = filterDraft.trim(); - const filterActive = trimmedFilter.length > 0; - - interface FilterResult { - path: string; - name: string; - } - - const filterResults = useMemo(() => { - if (!filterActive) return []; - const needle = trimmedFilter.toLowerCase(); - - if (fullFileList !== null) { - // Use complete file list from API; rank filename matches above path-only matches - const filenameMatches: string[] = []; - const pathOnlyMatches: string[] = []; - for (const p of fullFileList) { - const lp = p.toLowerCase(); - if (!lp.includes(needle)) continue; - const bn = basename(p).toLowerCase(); - if (bn.includes(needle)) { - filenameMatches.push(p); - } else { - pathOnlyMatches.push(p); - } - } - filenameMatches.sort((a, b) => a.localeCompare(b)); - pathOnlyMatches.sort((a, b) => a.localeCompare(b)); - return [...filenameMatches, ...pathOnlyMatches] - .slice(0, 50) - .map((p) => ({ path: p, name: basename(p) })); - } - - // Fallback: use cache-based flat list (only loaded directories, files only) - return flattenedAll - .filter((e) => e.kind === 'file' && e.path.toLowerCase().includes(needle)) - .slice(0, 50) - .map((e) => ({ path: e.path, name: e.name })); - }, [filterActive, trimmedFilter, fullFileList, flattenedAll]); - - // Keyboard navigation - const [highlightedPath, setHighlightedPath] = useState(null); - const treeRef = useRef(null); - - // Reset highlight if it falls out of the current list (e.g. when filter - // changes or dirs collapse). - useEffect(() => { - if (!highlightedPath) return; - const list = filterActive ? filterResults : flattenedVisible; - if (!list.some((e) => e.path === highlightedPath)) { - setHighlightedPath(null); - } - }, [highlightedPath, filterActive, filterResults, flattenedVisible]); - - function onTreeKeyDown(e: KeyboardEvent) { - if (filterActive) { - if (filterResults.length === 0) return; - const idx = highlightedPath - ? filterResults.findIndex((entry) => entry.path === highlightedPath) - : -1; - if (e.key === 'ArrowDown') { - e.preventDefault(); - const next = idx < 0 ? 0 : Math.min(filterResults.length - 1, idx + 1); - const target = filterResults[next]; - if (target) setHighlightedPath(target.path); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - const next = idx <= 0 ? 0 : idx - 1; - const target = filterResults[next]; - if (target) setHighlightedPath(target.path); - return; - } - if (e.key === 'Enter') { - if (idx < 0) return; - const target = filterResults[idx]; - if (!target) return; - e.preventDefault(); - // Filter results are always files (API returns only files) - selectFile(target.path); - } - return; - } - - // Tree mode: use flattenedVisible which has kind info - const list = flattenedVisible; - if (list.length === 0) return; - const idx = highlightedPath - ? list.findIndex((entry) => entry.path === highlightedPath) - : -1; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - const next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1); - const target = list[next]; - if (target) setHighlightedPath(target.path); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - const next = idx <= 0 ? 0 : idx - 1; - const target = list[next]; - if (target) setHighlightedPath(target.path); - return; - } - if (e.key === 'Enter') { - if (idx < 0) return; - const target = list[idx]; - if (!target) return; - e.preventDefault(); - if (target.kind === 'dir') { - toggleDir(target.path); - } else { - selectFile(target.path); - } - } - } - - // Line selection state - const [selectedLines, setSelectedLines] = useState>(new Set()); - const [selectionAnchor, setSelectionAnchor] = useState(null); - - function handleLineClick(lineNo: number, shiftKey: boolean) { - if (shiftKey && selectionAnchor !== null) { - const start = Math.min(selectionAnchor, lineNo); - const end = Math.max(selectionAnchor, lineNo); - const range = new Set(); - for (let i = start; i <= end; i++) range.add(i); - setSelectedLines(range); - } else { - setSelectedLines(prev => { - const next = new Set(prev); - if (next.has(lineNo)) { - next.delete(lineNo); - } else { - next.add(lineNo); - } - return next; - }); - setSelectionAnchor(lineNo); - } - } - - // Viewer state - const [viewer, setViewer] = useState<{ - path: string; - state: 'loading' | 'ready' | 'error'; - result?: ViewFileResult; - error?: string; - } | null>(null); - - useEffect(() => { - if (!openFile) { - setViewer(null); - return; - } - let cancelled = false; - setViewer({ path: openFile, state: 'loading' }); - (async () => { - try { - const result = await api.projects.viewFile(projectId, openFile); - if (cancelled) return; - setViewer({ path: openFile, state: 'ready', result }); - } catch (err) { - if (cancelled) return; - let message: string; - if (err instanceof ApiError) { - const apiMsg = - typeof err.body === 'object' && - err.body !== null && - 'error' in err.body - ? String((err.body as { error: unknown }).error) - : err.message; - if (err.status === 404) { - message = 'File not found'; - } else if (apiMsg.toLowerCase().includes('too large')) { - message = 'File too large to view'; - } else if ( - apiMsg.toLowerCase().includes('outside') || - apiMsg.toLowerCase().includes('not a file') || - apiMsg.toLowerCase().includes('path') - ) { - message = 'Cannot view files outside project'; - } else { - message = apiMsg; - } - } else if (err instanceof Error) { - message = err.message; - } else { - message = 'Failed to load file'; - } - setViewer({ path: openFile, state: 'error', error: message }); - } - })(); - return () => { - cancelled = true; - }; - }, [openFile, projectId]); - - // Clear line selection when open file changes - useEffect(() => { - setSelectedLines(new Set()); - setSelectionAnchor(null); - }, [openFile]); - - // Compute selection range for the floating action bar (loop avoids call-stack limit on spread) - let selectionMin = 0; - let selectionMax = 0; - if (selectedLines.size > 0) { - for (const n of selectedLines) { - if (selectionMin === 0 || n < selectionMin) selectionMin = n; - if (n > selectionMax) selectionMax = n; - } - } - - function handleAttachLines() { - if (!openFile || !viewer?.result || selectedLines.size === 0) return; - const min = selectionMin; - const max = selectionMax; - const selectedContent = viewer.result.content - .split('\n') - .slice(min - 1, max) - .join('\n'); - sessionEvents.emit({ - type: 'attach_chat_file', - attachment: { - kind: 'lines', - filename: openFile, - language: inferLanguage(openFile) ?? null, - content: selectedContent, - range: [min, max], - source: 'line-select', - }, - }); - setSelectedLines(new Set()); - setSelectionAnchor(null); - } - - // Root errors / loading - const rootEntries = cache.get(''); - const rootLoading = loadingDirs.has('') && !rootEntries; - const rootError = dirErrors.get(''); - - return ( -
-
- onFilterInput(e.target.value)} - placeholder="Filter files..." - className="w-full px-2 py-1 text-xs bg-background border border-border rounded outline-none focus:border-ring" - aria-label="Filter files" - /> -
-
-
- {rootLoading && ( -
- Loading... -
- )} - {rootError && ( -
- {rootError} -
- )} - {!rootLoading && !rootError && filterActive && ( -
    - {filterResults.length === 0 ? ( -
  • - No matches -
  • - ) : ( - filterResults.map((entry) => { - const isActive = openFile === entry.path; - const isHighlight = highlightedPath === entry.path; - return ( -
  • -
    { - setHighlightedPath(entry.path); - selectFile(entry.path); - }} - > - - - {entry.name} - {entry.path} - -
    -
  • - ); - }) - )} -
- )} - {!rootLoading && !rootError && !filterActive && rootEntries && ( - - )} -
-
- {!openFile && ( -
- Select a file to view -
- )} - {openFile && ( - <> -
- - {basename(openFile)} - - -
-
- {viewer?.state === 'loading' && ( -
- Loading... -
- )} - {viewer?.state === 'error' && ( -
- {viewer.error} -
- )} - {viewer?.state === 'ready' && viewer.result && ( -
- {selectedLines.size > 0 && ( -
- - {selectedLines.size === 1 - ? `Attach line ${selectionMin} to chat` - : `Attach lines ${selectionMin}–${selectionMax} to chat`} - - -
- )} - {viewer.result.truncated && ( -
- Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total. -
- )} - -
- )} -
- - )} -
-
-
- ); -} diff --git a/apps/web/src/hooks/useSessionChats.ts b/apps/web/src/hooks/useSessionChats.ts new file mode 100644 index 0000000..6af6417 --- /dev/null +++ b/apps/web/src/hooks/useSessionChats.ts @@ -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>; + createChat: (paneIdx: number) => Promise; + archiveChat: (chatId: string) => Promise; + unarchiveChat: (chatId: string) => Promise; + deleteChat: (chatId: string) => Promise; + renameChat: (chatId: string, name: string) => Promise; + handleLandingSend: (paneIdx: number, content: string) => Promise; +} + +export function useSessionChats( + sessionId: string, + opts: UseSessionChatsOpts, +): UseSessionChatsResult { + const [chats, setChats] = useState([]); + const chatsRef = useRef([]); + 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, + }; +} diff --git a/apps/web/src/hooks/useWorkspacePanes.ts b/apps/web/src/hooks/useWorkspacePanes.ts new file mode 100644 index 0000000..831d52e --- /dev/null +++ b/apps/web/src/hooks/useWorkspacePanes.ts @@ -0,0 +1,339 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { DragEvent } from 'react'; +import { toast } from 'sonner'; +import type { WorkspacePane } from '@/api/types'; +import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane'; + +export const MAX_PANES = 5; +const STORAGE_KEY = 'boocode.workspace.panes'; + +function generateId(): string { + return crypto.randomUUID(); +} + +function emptyPane(): WorkspacePane { + return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 }; +} + +function chatPane(chatId: string): WorkspacePane { + return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; +} + +function loadPanes(sessionId: string): WorkspacePane[] | null { + try { + const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`); + if (!raw) return null; + const parsed = JSON.parse(raw) as WorkspacePane[]; + if (!Array.isArray(parsed) || parsed.length === 0) return null; + return parsed; + } catch { + return null; + } +} + +function savePanes(sessionId: string, panes: WorkspacePane[]): void { + try { + localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes)); + } catch { /* quota or disabled */ } +} + +export interface UseWorkspacePanesResult { + panes: WorkspacePane[]; + activePaneIdx: number; + setActivePaneIdx: React.Dispatch>; + activePaneIdxRef: React.MutableRefObject; + openChatInPane: (paneIdx: number, chatId: string) => void; + switchTab: (paneIdx: number, tabIdx: number) => void; + removeTab: (paneIdx: number, chatId: string) => void; + closeOtherTabs: (paneIdx: number, keepChatId: string) => void; + closeTabsToRight: (paneIdx: number, pivotChatId: string) => void; + closeAllTabs: (paneIdx: number) => void; + showLandingPage: (paneIdx: number) => void; + addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void; + removePane: (idx: number) => void; + removeChatFromPanes: (chatId: string) => void; + initializeFirstChatIfEmpty: (chatId: string) => void; + handlePaneDragStart: (idx: number) => (e: DragEvent) => void; + handlePaneDragOver: (idx: number) => (e: DragEvent) => void; + handlePaneDragLeave: () => void; + handlePaneDrop: (targetIdx: number) => (e: DragEvent) => void; + handlePaneDragEnd: () => void; + dragOverIdx: number | null; + draggingIdxRef: React.MutableRefObject; +} + +export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { + const [panes, setPanes] = useState(() => { + return loadPanes(sessionId) ?? [emptyPane()]; + }); + const [activePaneIdx, setActivePaneIdx] = useState(0); + const draggingIdxRef = useRef(null); + const [dragOverIdx, setDragOverIdx] = useState(null); + + useEffect(() => { + 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; + + const openChatInPane = useCallback((paneIdx: number, chatId: string) => { + setPanes((prev) => { + const next = [...prev]; + const pane = next[paneIdx]!; + const existing = pane.chatIds.indexOf(chatId); + if (existing >= 0) { + next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing }; + } else { + const newIds = [...pane.chatIds, chatId]; + next[paneIdx] = { + ...pane, + kind: 'chat', + chatId, + chatIds: newIds, + activeChatIdx: newIds.length - 1, + }; + } + return next; + }); + setActivePaneIdx(paneIdx); + }, []); + + const switchTab = useCallback((paneIdx: number, tabIdx: number) => { + setPanes((prev) => { + const next = [...prev]; + const pane = next[paneIdx]!; + const chatId = pane.chatIds[tabIdx]; + if (!chatId) return prev; + next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx }; + return next; + }); + }, []); + + const removeTab = useCallback((paneIdx: number, chatId: string) => { + setPanes((prev) => { + const next = [...prev]; + const pane = next[paneIdx]!; + const nextIds = pane.chatIds.filter((id) => id !== chatId); + if (nextIds.length === 0) { + next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; + } else { + const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); + next[paneIdx] = { + ...pane, + chatIds: nextIds, + activeChatIdx: nextActiveIdx, + chatId: nextIds[nextActiveIdx], + }; + } + return next; + }); + }, []); + + // Keep only the right-clicked tab open in this pane. + const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => { + setPanes((prev) => { + const next = [...prev]; + const pane = next[paneIdx]!; + const keepIdx = pane.chatIds.indexOf(keepChatId); + if (keepIdx < 0) return prev; + next[paneIdx] = { + ...pane, + kind: 'chat', + chatId: keepChatId, + chatIds: [keepChatId], + activeChatIdx: 0, + }; + return next; + }); + }, []); + + // Close every tab to the right of the right-clicked one. + const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => { + setPanes((prev) => { + const next = [...prev]; + const pane = next[paneIdx]!; + const pivotIdx = pane.chatIds.indexOf(pivotChatId); + if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev; + const nextIds = pane.chatIds.slice(0, pivotIdx + 1); + const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1); + next[paneIdx] = { + ...pane, + chatIds: nextIds, + activeChatIdx: nextActiveIdx, + chatId: nextIds[nextActiveIdx], + }; + return next; + }); + }, []); + + // Close every tab in this pane; land on landing page. + const closeAllTabs = useCallback((paneIdx: number) => { + setPanes((prev) => { + const next = [...prev]; + const pane = next[paneIdx]!; + next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 }; + return next; + }); + }, []); + + const showLandingPage = useCallback((paneIdx: number) => { + setPanes((prev) => { + const next = [...prev]; + const pane = next[paneIdx]!; + next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined }; + return next; + }); + }, []); + + const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => { + if (kind === 'terminal') { + toast('Terminal panes coming in BooTerm'); + return; + } + if (kind === 'agent') { + toast('Agent panes coming in BooCoder'); + return; + } + setPanes((prev) => { + if (prev.length >= MAX_PANES) { + toast.error(`Maximum ${MAX_PANES} panes`); + return prev; + } + const next = [...prev, emptyPane()]; + setActivePaneIdx(next.length - 1); + return next; + }); + }, []); + + const removePane = useCallback((idx: number) => { + setPanes((prev) => { + if (prev.length <= 1) return prev; + const next = prev.filter((_, i) => i !== idx); + setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); + return next; + }); + }, []); + + // Replaces a single empty default pane with a chat pane. Used by the initial + // chat fetch to land on the most-recent open chat if no saved pane state. + const initializeFirstChatIfEmpty = useCallback((chatId: string) => { + setPanes((prev) => { + if (prev.length === 1 && prev[0]!.kind === 'empty') { + return [chatPane(chatId)]; + } + return prev; + }); + }, []); + + const removeChatFromPanes = useCallback((chatId: string) => { + setPanes((prev) => prev.map((p) => { + const idx = p.chatIds.indexOf(chatId); + if (idx < 0) return p; + const nextIds = p.chatIds.filter((id) => id !== chatId); + if (nextIds.length === 0) { + return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 }; + } + const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1); + return { + ...p, + chatIds: nextIds, + activeChatIdx: nextActiveIdx, + chatId: nextIds[nextActiveIdx], + }; + })); + }, []); + + const handlePaneDragStart = useCallback( + (idx: number) => (e: DragEvent) => { + draggingIdxRef.current = idx; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + }, + [] + ); + + const handlePaneDragOver = useCallback( + (idx: number) => (e: DragEvent) => { + 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) => { + 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); + }, []); + + return { + panes, + activePaneIdx, + setActivePaneIdx, + activePaneIdxRef, + openChatInPane, + switchTab, + removeTab, + closeOtherTabs, + closeTabsToRight, + closeAllTabs, + showLandingPage, + addSplitPane, + removePane, + removeChatFromPanes, + initializeFirstChatIfEmpty, + handlePaneDragStart, + handlePaneDragOver, + handlePaneDragLeave, + handlePaneDrop, + handlePaneDragEnd, + dragOverIdx, + draggingIdxRef, + }; +}