diff --git a/package-lock.json b/package-lock.json index d4ba5a17..2ef8760d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "@xterm/xterm": "^5.5.0", "adm-zip": "^0.5.16", "bcryptjs": "^3.0.2", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "^12.9.0", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -68,7 +68,8 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", - "ws": "^8.14.2" + "ws": "^8.14.2", + "zustand": "^5.0.12" }, "bin": { "dr-claw": "server/cli.js", @@ -6270,9 +6271,9 @@ "license": "Apache-2.0" }, "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -18791,6 +18792,35 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index bd863c26..2eab40e4 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "@xterm/xterm": "^5.5.0", "adm-zip": "^0.5.16", "bcryptjs": "^3.0.2", - "better-sqlite3": "^12.2.0", + "better-sqlite3": "^12.9.0", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -124,7 +124,8 @@ "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "tailwind-merge": "^3.3.1", - "ws": "^8.14.2" + "ws": "^8.14.2", + "zustand": "^5.0.12" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 2cfefdf8..afe04a59 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -13,6 +13,7 @@ import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; import { useInteractionTelemetry } from '../../hooks/useInteractionTelemetry'; import { useUiPreferences } from '../../hooks/useUiPreferences'; +import { useSessionTabsStore } from '../../stores/useSessionTabsStore'; import { ensureTelemetryDefaultEnabled, isTelemetryEnabled, @@ -151,6 +152,17 @@ export default function AppContent() { }; }, [isConnected, sendMessage]); + // Sync session tab store -> selectedSession when user clicks a tab in the tab bar + const tabStoreActiveId = useSessionTabsStore((s) => s.activeTabId); + const tabStoreTabs = useSessionTabsStore((s) => s.tabs); + useEffect(() => { + if (!tabStoreActiveId) return; + if (selectedSession?.id === tabStoreActiveId) return; + const tab = tabStoreTabs.find((t) => t.id === tabStoreActiveId); + if (!tab) return; + handleNavigateToSession(tab.id, tab.provider, tab.projectName); + }, [tabStoreActiveId, tabStoreTabs, selectedSession?.id, handleNavigateToSession]); + useEffect(() => { if (!isDesktop || !onDesktopEvent) { return; diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 99b4a0d6..370108e5 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -18,6 +18,7 @@ import { RESUMING_STATUS_TEXT } from '../types/types'; import i18n from '../../../i18n/config'; import type { ChatMessage, PendingPermissionRequest } from '../types/types'; import type { Project, ProjectSession, SessionNavigationSource, SessionProvider } from '../../../types/app'; +import { useSessionTabsStore } from '../../../stores/useSessionTabsStore'; type PendingViewSession = { sessionId: string | null; @@ -539,11 +540,43 @@ export function useChatRealtimeHandlers({ }); }; + // Route messages for background (non-active) sessions to the tab store + const updateBackgroundSessionStatus = (bgSessionId: string, msgType: string) => { + const store = useSessionTabsStore.getState(); + const isTabOpen = store.tabs.some((t) => t.id === bgSessionId); + if (!isTabOpen) return; + + const prevSeq = store.backgroundStatus[bgSessionId]?.messageSeq ?? 0; + const isComplete = lifecycleMessageTypes.has(msgType); + if (isComplete) { + const wasLoading = store.backgroundStatus[bgSessionId]?.isLoading ?? false; + store.setBackgroundStatus(bgSessionId, { + isLoading: false, + statusText: null, + hasUnread: wasLoading, + messageSeq: prevSeq + 1, + }); + } else if (msgType.endsWith('-status') || msgType.endsWith('-response') || msgType.endsWith('-output')) { + const statusText = latestMessage.data?.status || latestMessage.data?.text || null; + const tokens = typeof latestMessage.data?.tokens === 'number' ? latestMessage.data.tokens : undefined; + store.setBackgroundStatus(bgSessionId, { + isLoading: true, + hasUnread: false, + ...(statusText != null && { statusText: String(statusText) }), + ...(tokens != null && { tokenCount: tokens }), + messageSeq: prevSeq + 1, + }); + } + }; + if (!shouldBypassSessionFilter) { if (!activeViewSessionId) { if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { handleBackgroundLifecycle(latestMessage.sessionId); } + if (latestMessage.sessionId) { + updateBackgroundSessionStatus(latestMessage.sessionId, String(latestMessage.type)); + } if (!isUnscopedError) { return; } @@ -554,6 +587,9 @@ export function useChatRealtimeHandlers({ } if (latestMessage.sessionId !== activeViewSessionId) { + if (latestMessage.sessionId) { + updateBackgroundSessionStatus(latestMessage.sessionId, String(latestMessage.type)); + } if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) { handleBackgroundLifecycle(latestMessage.sessionId); } @@ -588,6 +624,13 @@ export function useChatRealtimeHandlers({ pendingViewSessionRef.current.sessionId = latestMessage.sessionId; } setIsSystemSessionChange(true); + if (temporarySessionId) { + useSessionTabsStore.getState().replaceTabSessionId( + temporarySessionId, + { id: latestMessage.sessionId, __provider: createdSessionProvider, __projectName: selectedProject?.name } as ProjectSession, + selectedProject?.name || '', + ); + } onReplaceTemporarySession?.(latestMessage.sessionId); onNavigateToSession?.(latestMessage.sessionId, createdSessionProvider, selectedProject?.name, { source: 'system' }); setPendingPermissionRequests((previous) => @@ -827,6 +870,10 @@ export function useChatRealtimeHandlers({ sessionStorage.removeItem('pendingSessionId'); } if (selectedProject && latestMessage.exitCode === 0) { + // Clear both the session-scoped and legacy project-scoped recovery keys. + if (completedSessionId) { + safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}_${completedSessionId}`); + } safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); } setPendingPermissionRequests([]); @@ -1324,7 +1371,12 @@ export function useChatRealtimeHandlers({ } sessionStorage.removeItem('pendingSessionId'); } - if (selectedProject) safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + if (selectedProject) { + if (codexActualSessionId) { + safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}_${codexActualSessionId}`); + } + safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); + } break; } @@ -1340,6 +1392,13 @@ export function useChatRealtimeHandlers({ case 'session-aborted': { const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; const abortedSessionId = latestMessage.sessionId || currentSessionId; + // Guard: with multiple ChatInterface instances mounted (one per tab), + // session-aborted is a global message that reaches all of them. Only the + // instance that owns the aborted session should update chat state. + const thisInstanceId = selectedSession?.id || currentSessionId; + if (abortedSessionId && thisInstanceId && abortedSessionId !== thisInstanceId) { + break; + } if (latestMessage.success !== false) { clearLoadingIndicators(); markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId); diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 5b453bd6..88ae1a59 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -13,6 +13,7 @@ import { createCachedDiffCalculator, type DiffCalculator, } from '../utils/messageTransforms'; +import { useSessionTabsStore } from '../../../stores/useSessionTabsStore'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; @@ -101,12 +102,21 @@ export function useChatSessionState({ const [chatMessages, _setChatMessages] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { - const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); + // Prefer session-scoped key to avoid collisions when multiple sessions + // from the same project are open simultaneously (split pane / multi-tab). + const sessionScopedKey = + selectedSession?.id && !selectedSession.id.startsWith('new-session-') + ? `chat_messages_${selectedProject.name}_${selectedSession.id}` + : null; + const saved = + (sessionScopedKey ? safeLocalStorage.getItem(sessionScopedKey) : null) ?? + safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); if (saved) { try { return hydrateStoredChatMessages(JSON.parse(saved) as ChatMessage[]); } catch { console.error('Failed to parse saved chat messages, resetting'); + if (sessionScopedKey) safeLocalStorage.removeItem(sessionScopedKey); safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); return []; } @@ -234,7 +244,6 @@ export function useChatSessionState({ } const data = await response.json(); - console.log('[DEBUG] Received session messages data:', data); if (isInitialLoad && data.tokenUsage) { setTokenBudget(data.tokenUsage); } @@ -439,6 +448,41 @@ export function useChatSessionState({ }, 200); }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); + // Keep refs to latest values so snapshot save never captures stale closures + const chatMessagesRef = useRef(chatMessages); + chatMessagesRef.current = chatMessages; + const isLoadingRef = useRef(isLoading); + isLoadingRef.current = isLoading; + const claudeStatusRef = useRef(claudeStatus); + claudeStatusRef.current = claudeStatus; + const tokenBudgetRef = useRef(tokenBudget); + tokenBudgetRef.current = tokenBudget; + const canAbortSessionRef = useRef(canAbortSession); + canAbortSessionRef.current = canAbortSession; + + const prevSessionIdRef = useRef(selectedSession?.id || null); + + // Save snapshot of outgoing session when selectedSession changes + useEffect(() => { + const outgoingId = prevSessionIdRef.current; + const incomingId = selectedSession?.id || null; + + if (outgoingId && outgoingId !== incomingId && chatMessagesRef.current.length > 0) { + const container = scrollContainerRef.current; + useSessionTabsStore.getState().saveSnapshot(outgoingId, { + messages: chatMessagesRef.current, + isLoading: isLoadingRef.current, + statusText: claudeStatusRef.current?.text ?? null, + tokenCount: claudeStatusRef.current?.tokens ?? 0, + tokenBudget: tokenBudgetRef.current, + scrollTop: container?.scrollTop ?? 0, + canAbort: canAbortSessionRef.current, + }); + } + + prevSessionIdRef.current = incomingId; + }, [selectedSession?.id]); + useEffect(() => { const loadMessages = async () => { if (selectedSession && selectedProject) { @@ -446,6 +490,62 @@ export function useChatSessionState({ isLoadingSessionRef.current = true; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; + + // Try restoring from snapshot cache for instant tab switching + if (sessionChanged && !isSystemSessionChange) { + const snapshot = useSessionTabsStore.getState().getSnapshot(selectedSession.id); + if (snapshot && snapshot.messages.length > 0) { + useSessionTabsStore.getState().clearSnapshot(selectedSession.id); + resetStreamingState(); + pendingViewSessionRef.current = null; + setChatMessages(snapshot.messages); + setSessionMessages([]); + setIsLoading(snapshot.isLoading); + setCanAbortSession(snapshot.canAbort); + setTokenBudget(snapshot.tokenBudget); + setStatusTextOverride(null); + setCurrentSessionId(selectedSession.id); + messagesOffsetRef.current = 0; + setHasMoreMessages(false); + setTotalMessages(snapshot.messages.length); + setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); + setAllMessagesLoaded(false); + allMessagesLoadedRef.current = false; + + if (snapshot.isLoading && snapshot.statusText) { + setClaudeStatus({ text: snapshot.statusText, tokens: snapshot.tokenCount, can_interrupt: true }); + } else { + setClaudeStatus(null); + // Clear persisted timer so the "Resuming..." effect doesn't kick in + clearSessionTimerStart(selectedSession.id); + } + + // Clear any pending status validation to prevent spurious "Resuming..." + setPendingStatusValidationSessionId(null); + + // Only check session status if the snapshot indicated the session was active + if (snapshot.isLoading && ws && selectedSession?.id) { + markSessionStatusCheckPending(selectedSession.id); + sendMessage({ + type: 'check-session-status', + sessionId: selectedSession.id, + provider: currentProvider, + }); + } + + // Restore scroll position after next paint + const savedScrollTop = snapshot.scrollTop; + requestAnimationFrame(() => { + if (scrollContainerRef.current && savedScrollTop > 0) { + scrollContainerRef.current.scrollTop = savedScrollTop; + } + }); + + isLoadingSessionRef.current = false; + return; + } + } + if (sessionChanged) { if (!isSystemSessionChange) { resetStreamingState(); @@ -469,7 +569,6 @@ export function useChatSessionState({ if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); setTokenBudget(null); - // Only set isLoading to false if it's NOT in the processingSessions set const isProcessing = processingSessions?.has(selectedSession.id) || pendingStatusValidationSessionIdRef.current === selectedSession.id; @@ -478,8 +577,6 @@ export function useChatSessionState({ } } - // Always check status if we have a websocket and a session, - // especially on initial load or reconnect. if (ws && selectedSession?.id) { markSessionStatusCheckPending(selectedSession.id); sendMessage({ @@ -614,10 +711,14 @@ export function useChatSessionState({ }, [convertedMessages, setChatMessages]); useEffect(() => { - if (selectedProject && chatMessages.length > 0) { - safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages)); - } - }, [chatMessages, selectedProject]); + if (!selectedProject || chatMessages.length === 0) return; + // Use session-scoped key when possible to avoid cross-session collisions. + const key = + selectedSession?.id && !selectedSession.id.startsWith('new-session-') + ? `chat_messages_${selectedProject.name}_${selectedSession.id}` + : `chat_messages_${selectedProject.name}`; + safeLocalStorage.setItem(key, JSON.stringify(chatMessages)); + }, [chatMessages, selectedProject, selectedSession?.id]); useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { diff --git a/src/components/chat/view/subcomponents/SessionTabBar.tsx b/src/components/chat/view/subcomponents/SessionTabBar.tsx new file mode 100644 index 00000000..c705adcd --- /dev/null +++ b/src/components/chat/view/subcomponents/SessionTabBar.tsx @@ -0,0 +1,172 @@ +import React, { useCallback, useRef } from 'react'; +import { X, Columns2, Loader2, CircleDot } from 'lucide-react'; +import { cn } from '../../../../lib/utils'; +import { useSessionTabsStore } from '../../../../stores/useSessionTabsStore'; +import SessionProviderLogo from '../../../SessionProviderLogo'; +import type { SessionTab } from '../../../../types/sessionTabs'; + +function tabDisplayName(tab: SessionTab): string { + return tab.session.summary || tab.session.title || tab.session.name || tab.id.slice(0, 8); +} + +function TabItem({ + tab, + isActive, + isSecondary, + onActivate, + onClose, + onSplitOpen, + dragIndex, + onDragStart, + onDragOver, + onDrop, +}: { + tab: SessionTab; + isActive: boolean; + isSecondary: boolean; + onActivate: () => void; + onClose: (e: React.MouseEvent) => void; + onSplitOpen: (e: React.MouseEvent) => void; + dragIndex: number; + onDragStart: (idx: number) => void; + onDragOver: (e: React.DragEvent, idx: number) => void; + onDrop: (e: React.DragEvent, idx: number) => void; +}) { + const bgStatus = useSessionTabsStore((s) => s.backgroundStatus[tab.id]); + const isLoading = bgStatus?.isLoading ?? false; + const hasUnread = bgStatus?.hasUnread ?? false; + const tokenCount = bgStatus?.tokenCount ?? 0; + + const statusIcon = isLoading ? ( + + ) : hasUnread ? ( + + ) : null; + + return ( +
onDragStart(dragIndex)} + onDragOver={(e) => onDragOver(e, dragIndex)} + onDrop={(e) => onDrop(e, dragIndex)} + onClick={onActivate} + onContextMenu={(e) => { + e.preventDefault(); + onSplitOpen(e); + }} + title={tabDisplayName(tab)} + > + + + {statusIcon} + + {tabDisplayName(tab)} + + {isLoading && tokenCount > 0 && ( + + {tokenCount > 999 ? `${Math.round(tokenCount / 1000)}k` : tokenCount} + + )} + + +
+ ); +} + +export default function SessionTabBar() { + const tabs = useSessionTabsStore((s) => s.tabs); + const activeTabId = useSessionTabsStore((s) => s.activeTabId); + const secondaryTabId = useSessionTabsStore((s) => s.secondaryTabId); + const splitMode = useSessionTabsStore((s) => s.splitMode); + const { setActiveTab, removeTab, reorderTab, enableSplit, disableSplit } = useSessionTabsStore(); + + const dragIndexRef = useRef(null); + + const handleDragStart = useCallback((idx: number) => { + dragIndexRef.current = idx; + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, _idx: number) => { + e.preventDefault(); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent, toIdx: number) => { + e.preventDefault(); + const fromIdx = dragIndexRef.current; + if (fromIdx !== null && fromIdx !== toIdx) { + reorderTab(fromIdx, toIdx); + } + dragIndexRef.current = null; + }, + [reorderTab], + ); + + if (tabs.length <= 1) { + return null; + } + + return ( +
+
+ {tabs.map((tab, idx) => ( + setActiveTab(tab.id)} + onClose={(e) => { + e.stopPropagation(); + removeTab(tab.id); + }} + onSplitOpen={(e) => { + e.stopPropagation(); + if (splitMode && secondaryTabId === tab.id) { + disableSplit(); + } else if (tab.id !== activeTabId) { + enableSplit(tab.id); + } + }} + dragIndex={idx} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + /> + ))} +
+ + {tabs.length >= 2 && ( + + )} +
+ ); +} diff --git a/src/components/chat/view/subcomponents/SplitPaneContainer.tsx b/src/components/chat/view/subcomponents/SplitPaneContainer.tsx new file mode 100644 index 00000000..f0822f9a --- /dev/null +++ b/src/components/chat/view/subcomponents/SplitPaneContainer.tsx @@ -0,0 +1,162 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { useSessionTabsStore } from '../../../../stores/useSessionTabsStore'; +import type { ChatInterfaceProps } from '../../types/types'; +import type { ProjectSession } from '../../../../types/app'; + +interface SplitPaneContainerProps { + ChatInterfaceComponent: React.ComponentType; + baseChatProps: ChatInterfaceProps; + /** All projects for resolving each tab's project */ + projects: Array<{ name: string; sessions?: ProjectSession[]; [k: string]: unknown }>; +} + +const SPLIT_MIN_WIDTH_PCT = 25; +const SPLIT_MAX_WIDTH_PCT = 75; +const SPLIT_STORAGE_KEY = 'dr-claw-split-ratio'; + +function readStoredRatio(): number { + if (typeof window === 'undefined') return 50; + const v = window.localStorage.getItem(SPLIT_STORAGE_KEY); + const n = v ? Number(v) : NaN; + return Number.isFinite(n) && n >= SPLIT_MIN_WIDTH_PCT && n <= SPLIT_MAX_WIDTH_PCT ? n : 50; +} + +/** + * Renders one ChatInterface instance per open tab and keeps them ALL mounted. + * Switching tabs is a pure CSS display toggle — no unmount/remount, no API reload. + * + * Layout: + * - Non-split: active tab is `position:absolute; inset:0`, all others are `display:none`. + * - Split: primary (left) and secondary (right) are positioned absolutely; + * all other tabs are `display:none`. + * + * The draggable divider is rendered as a separate absolutely-positioned element + * so it doesn't affect the tab instances' layout. + */ +export default function SplitPaneContainer({ + ChatInterfaceComponent, + baseChatProps, + projects, +}: SplitPaneContainerProps) { + const tabs = useSessionTabsStore((s) => s.tabs); + const activeTabId = useSessionTabsStore((s) => s.activeTabId); + const splitMode = useSessionTabsStore((s) => s.splitMode); + const secondaryTabId = useSessionTabsStore((s) => s.secondaryTabId); + + const [splitRatio, setSplitRatio] = useState(readStoredRatio); + const containerRef = useRef(null); + const isResizing = useRef(false); + + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + if (!isResizing.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const pct = ((ev.clientX - rect.left) / rect.width) * 100; + setSplitRatio(Math.min(SPLIT_MAX_WIDTH_PCT, Math.max(SPLIT_MIN_WIDTH_PCT, pct))); + }; + + const onMouseUp = () => { + isResizing.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + setSplitRatio((r) => { + window.localStorage.setItem(SPLIT_STORAGE_KEY, String(Math.round(r))); + return r; + }); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, []); + + // No tabs yet (initial load before any session is selected). + if (tabs.length === 0) { + return ; + } + + return ( +
+ {tabs.map((tab) => { + const isPrimary = tab.id === activeTabId; + const isSecondary = splitMode && tab.id === secondaryTabId; + + // Resolve the project for this specific tab. + const tabProject = + (projects.find((p) => p.name === tab.projectName) as ChatInterfaceProps['selectedProject']) ?? + baseChatProps.selectedProject; + + // Compute CSS positioning for this pane. + let paneStyle: React.CSSProperties; + if (!isPrimary && !isSecondary) { + // Hidden: keep mounted but invisible (display:none preserves scroll & state). + paneStyle = { display: 'none' }; + } else if (!splitMode) { + // Single-pane: fill the entire container. + paneStyle = { position: 'absolute', inset: 0 }; + } else if (isPrimary) { + // Left pane: 0 → splitRatio%, with 2 px gap for the divider. + paneStyle = { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: `calc(${100 - splitRatio}% + 2px)`, + }; + } else { + // Right pane: splitRatio% → 100%, with 2 px gap for the divider. + paneStyle = { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: `calc(${splitRatio}% + 2px)`, + }; + } + + // Props overrides for each tab instance. + // Session-specific reactive props (external file changes, intake flows) + // only go to the active primary tab to avoid cross-instance interference. + const tabChatProps: ChatInterfaceProps = { + ...baseChatProps, + selectedProject: tabProject, + selectedSession: tab.session, + externalMessageUpdate: isPrimary ? baseChatProps.externalMessageUpdate : 0, + pendingAutoIntake: isPrimary ? baseChatProps.pendingAutoIntake : null, + importedProjectAnalysisPrompt: isPrimary + ? baseChatProps.importedProjectAnalysisPrompt + : null, + }; + + return ( +
+ +
+ ); + })} + + {/* Draggable divider — absolutely positioned, above all panes (z-10). */} + {splitMode && ( +
+ )} +
+ ); +} diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 85dc88b7..ea73e72b 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect } from 'react'; import ChatInterface from '../../chat/view/ChatInterface'; import SkillsDashboard from '../../SkillsDashboard'; @@ -10,12 +10,11 @@ import ProjectDashboard from '../../project-dashboard/view/ProjectDashboard'; import TrashDashboard from '../../project-dashboard/view/TrashDashboard'; import NewsDashboard from '../../news-dashboard/view/NewsDashboard'; -import ChatTabBar from '../../chat/view/ChatTabBar'; -import { useChatTabs } from '../../../hooks/useChatTabs'; import MainContentHeader from './subcomponents/MainContentHeader'; import MainContentStateView from './subcomponents/MainContentStateView'; +import SessionTabBar from '../../chat/view/subcomponents/SessionTabBar'; +import SplitPaneContainer from '../../chat/view/subcomponents/SplitPaneContainer'; import type { MainContentProps } from '../types/types'; -import { resolveChatTabSyncAction } from './chatTabSync'; import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useUiPreferences } from '../../../hooks/useUiPreferences'; @@ -61,9 +60,9 @@ function MainContent({ onChatFromReference, newSessionMode, onNewSessionModeChange, - sessionNavigationSource, - onResetNavigationSource, - onNewSession, + sessionNavigationSource: _sessionNavigationSource, + onResetNavigationSource: _onResetNavigationSource, + onNewSession: _onNewSession, }: MainContentProps) { const { preferences } = useUiPreferences(); const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; @@ -71,74 +70,6 @@ function MainContent({ const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue; const shouldShowTasksTab = false; - const handleActivateBlankTab = useCallback(() => { - if (selectedProject && onNewSession) { - onNewSession(selectedProject, newSessionMode); - } - }, [selectedProject, onNewSession, newSessionMode]); - - const chatTabs = useChatTabs( - selectedProject, - onNavigateToSession, - handleActivateBlankTab, - ); - - const { - activeTab: chatActiveTab, - tabs: chatTabList, - openNewTab, - openTab, - updateActiveTabSession, - switchTab, - closeTab, - } = chatTabs; - const chatActiveTabSessionId = chatActiveTab?.sessionId; - const chatTabCount = chatTabList.length; - - // Sync selectedSession changes into tab state using navigation source to - // distinguish user sidebar clicks from system session-created events. Runs - // on first render too so a pre-selected session (e.g. from URL) gets a tab. - useEffect(() => { - const currId = selectedSession?.id ?? null; - - const action = resolveChatTabSyncAction({ - activeAppTab: activeTab, - hasSelectedProject: Boolean(selectedProject), - nextSessionId: currId, - activeChatTabSessionId: chatActiveTabSessionId, - tabCount: chatTabCount, - navigationSource: sessionNavigationSource, - }); - - if (action === 'open-new-tab') { - openNewTab(); - } else if (action === 'update-active-tab-session' && currId && selectedProject) { - updateActiveTabSession(selectedSession!, selectedProject); - } else if (action === 'open-tab' && currId && selectedProject) { - openTab(selectedSession!, selectedProject); - } - - if (action !== 'noop') { - onResetNavigationSource(); - } - }, [ - selectedSession, - selectedProject, - activeTab, - sessionNavigationSource, - chatActiveTabSessionId, - chatTabCount, - openNewTab, - openTab, - updateActiveTabSession, - onResetNavigationSource, - ]); - - // When the active tab has no session (new chat via [+]), pass null to ChatInterface - const effectiveSession = chatActiveTabSessionId === null - ? null - : selectedSession; - useEffect(() => { if (selectedProject && selectedProject !== currentProject) { setCurrentProject?.(selectedProject); @@ -222,7 +153,6 @@ function MainContent({ { queueSkillCommandDraft(command); - // Select the most recent project if available, then switch to chat const recentProject = projects?.[0]; if (recentProject) { onProjectSelect(recentProject); @@ -323,49 +253,44 @@ function MainContent({
- { - if (selectedProject && onNewSession) { - onNewSession(selectedProject); - } - openNewTab(); - }} - /> - - - + +
+ + + +
{activeTab === 'survey' && ( diff --git a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx index dffafbf4..9ec6cf76 100644 --- a/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx @@ -1,6 +1,6 @@ import { Badge } from '../../../ui/badge'; import { Button } from '../../../ui/button'; -import { Check, Clock, Edit2, Trash2, X } from 'lucide-react'; +import { Check, Clock, Edit2, Loader2, Trash2, X } from 'lucide-react'; import type { TFunction } from 'i18next'; import { cn } from '../../../../lib/utils'; import { formatTimeAgo } from '../../../../utils/dateUtils'; @@ -8,6 +8,7 @@ import type { Project, ProjectSession, SessionProvider } from '../../../../types import type { SessionWithProvider, TouchHandlerFactory } from '../../types/types'; import { createSessionViewModel } from '../../utils/utils'; import SessionProviderLogo from '../../../SessionProviderLogo'; +import { useSessionTabsStore } from '../../../../stores/useSessionTabsStore'; const STAGE_TAG_TONE_BY_KEY: Record = { survey: 'border-sky-200/80 bg-sky-50 text-sky-700 dark:border-sky-900/70 dark:bg-sky-950/30 dark:text-sky-300', @@ -59,6 +60,10 @@ export default function SidebarSessionItem({ }: SidebarSessionItemProps) { const sessionView = createSessionViewModel(session, currentTime, t); const isSelected = selectedSession?.id === session.id; + const bgStatus = useSessionTabsStore((s) => s.backgroundStatus[session.id]); + const isBackgroundLoading = bgStatus?.isLoading ?? false; + const hasUnread = bgStatus?.hasUnread ?? false; + const bgTokenCount = bgStatus?.tokenCount ?? 0; const selectMobileSession = () => { onProjectSelect(project); @@ -190,21 +195,44 @@ export default function SidebarSessionItem({ {formatTimeAgo(sessionView.sessionTime, currentTime, t)}
- - {sessionView.messageCount} - - - {modeBadgeLabel} - - - - + {isBackgroundLoading ? ( + <> + + {bgTokenCount > 0 && ( + + {bgTokenCount > 999 ? `${Math.round(bgTokenCount / 1000)}k` : bgTokenCount} + + )} + + ) : hasUnread ? ( + <> + + + {sessionView.messageCount} + + + ) : ( + <> + + {sessionView.messageCount} + + + {modeBadgeLabel} + + + + + + )}
{stageTagBadges} diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 1d3d90b0..f607ae56 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -21,6 +21,7 @@ import type { SessionTag, TrashProject, } from '../types/app'; +import { useSessionTabsStore } from '../stores/useSessionTabsStore'; declare global { interface Window { @@ -707,6 +708,11 @@ export function useProjectsState({ setSelectedSession(sessionToSelect); } + if (sessionToSelect) { + const pName = projectToSelect?.name || selectedProject?.name || ''; + useSessionTabsStore.getState().addTab(sessionToSelect, pName); + } + if (shouldSwitchTab) { setActiveTab('chat'); } @@ -746,6 +752,9 @@ export function useProjectsState({ (session: ProjectSession) => { setSelectedSession(session); + const projectName = session.__projectName || selectedProject?.name || ''; + useSessionTabsStore.getState().addTab(session, projectName); + if (session.mode) { persistNewSessionMode(session.mode); setNewSessionMode(session.mode); diff --git a/src/stores/useSessionTabsStore.ts b/src/stores/useSessionTabsStore.ts new file mode 100644 index 00000000..c999cfd0 --- /dev/null +++ b/src/stores/useSessionTabsStore.ts @@ -0,0 +1,205 @@ +import { create } from 'zustand'; +import type { ProjectSession, SessionProvider } from '../types/app'; +import type { + BackgroundSessionStatus, + SessionSnapshot, + SessionTab, +} from '../types/sessionTabs'; + +interface SessionTabsState { + /** Ordered list of open tabs */ + tabs: SessionTab[]; + /** ID of the currently active (visible) tab */ + activeTabId: string | null; + /** Whether split-pane mode is on */ + splitMode: boolean; + /** ID of the tab shown in the secondary (right) pane */ + secondaryTabId: string | null; + /** Cached state snapshots keyed by session ID */ + snapshots: Record; + /** Live status for background (non-visible) sessions */ + backgroundStatus: Record; + + // --- Tab CRUD --- + addTab: (session: ProjectSession, projectName: string) => void; + removeTab: (sessionId: string) => void; + setActiveTab: (sessionId: string) => void; + reorderTab: (fromIndex: number, toIndex: number) => void; + + // --- Split pane --- + enableSplit: (secondarySessionId: string) => void; + disableSplit: () => void; + + // --- Snapshot cache --- + saveSnapshot: (sessionId: string, snapshot: SessionSnapshot) => void; + getSnapshot: (sessionId: string) => SessionSnapshot | undefined; + clearSnapshot: (sessionId: string) => void; + + // --- Background status --- + setBackgroundStatus: (sessionId: string, patch: Partial) => void; + clearBackgroundStatus: (sessionId: string) => void; + /** Mark a tab as read (clear hasUnread and loading indicators) */ + markTabRead: (sessionId: string) => void; + + /** Replace the temporary new-session-* tab id with the real session id from the server */ + replaceTabSessionId: (oldId: string, realSession: ProjectSession, projectName: string) => void; +} + +const MAX_OPEN_TABS = 20; + +export const useSessionTabsStore = create((set, get) => ({ + tabs: [], + activeTabId: null, + splitMode: false, + secondaryTabId: null, + snapshots: {}, + backgroundStatus: {}, + + addTab: (session, projectName) => { + const { tabs } = get(); + const existing = tabs.find((t) => t.id === session.id); + if (existing) { + set({ activeTabId: session.id }); + return; + } + const newTab: SessionTab = { + tabKey: (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID() + : `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`, + id: session.id, + session, + projectName, + provider: session.__provider || 'claude', + openedAt: Date.now(), + }; + const next = [...tabs, newTab]; + if (next.length > MAX_OPEN_TABS) { + const oldest = next.find((t) => t.id !== get().activeTabId && t.id !== get().secondaryTabId); + if (oldest) { + const idx = next.indexOf(oldest); + next.splice(idx, 1); + const { snapshots, backgroundStatus } = get(); + const { [oldest.id]: _s, ...restSnap } = snapshots; + const { [oldest.id]: _b, ...restBg } = backgroundStatus; + set({ tabs: next, activeTabId: session.id, snapshots: restSnap, backgroundStatus: restBg }); + return; + } + } + set({ tabs: next, activeTabId: session.id }); + }, + + removeTab: (sessionId) => { + const { tabs, activeTabId, secondaryTabId, snapshots, backgroundStatus } = get(); + const idx = tabs.findIndex((t) => t.id === sessionId); + if (idx === -1) return; + const next = tabs.filter((t) => t.id !== sessionId); + const { [sessionId]: _s, ...restSnap } = snapshots; + const { [sessionId]: _b, ...restBg } = backgroundStatus; + const updates: Partial = { tabs: next, snapshots: restSnap, backgroundStatus: restBg }; + + if (activeTabId === sessionId) { + const neighbor = next[Math.min(idx, next.length - 1)] ?? null; + updates.activeTabId = neighbor?.id ?? null; + } + if (secondaryTabId === sessionId) { + updates.secondaryTabId = null; + updates.splitMode = false; + } + set(updates); + }, + + setActiveTab: (sessionId) => { + // Always clear background tracking when the tab becomes active. + // If the session was loading in the background, the foreground loading + // spinner will take over; there's no need to keep a background entry. + const { [sessionId]: _, ...rest } = get().backgroundStatus; + set({ activeTabId: sessionId, backgroundStatus: rest }); + }, + + reorderTab: (fromIndex, toIndex) => { + const { tabs } = get(); + if (fromIndex < 0 || fromIndex >= tabs.length || toIndex < 0 || toIndex >= tabs.length) return; + const next = [...tabs]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + set({ tabs: next }); + }, + + enableSplit: (secondarySessionId) => { + const { tabs, activeTabId } = get(); + if (!tabs.find((t) => t.id === secondarySessionId)) return; + if (secondarySessionId === activeTabId) return; + set({ splitMode: true, secondaryTabId: secondarySessionId }); + }, + + disableSplit: () => { + set({ splitMode: false, secondaryTabId: null }); + }, + + saveSnapshot: (sessionId, snapshot) => { + set((state) => ({ + snapshots: { ...state.snapshots, [sessionId]: snapshot }, + })); + }, + + getSnapshot: (sessionId) => { + return get().snapshots[sessionId]; + }, + + clearSnapshot: (sessionId) => { + const { [sessionId]: _, ...rest } = get().snapshots; + set({ snapshots: rest }); + }, + + setBackgroundStatus: (sessionId, patch) => { + set((state) => { + const prev = state.backgroundStatus[sessionId] || { + isLoading: false, + statusText: null, + tokenCount: 0, + hasUnread: false, + messageSeq: 0, + }; + return { + backgroundStatus: { + ...state.backgroundStatus, + [sessionId]: { ...prev, ...patch }, + }, + }; + }); + }, + + clearBackgroundStatus: (sessionId) => { + const { [sessionId]: _, ...rest } = get().backgroundStatus; + set({ backgroundStatus: rest }); + }, + + markTabRead: (sessionId) => { + const { [sessionId]: _, ...rest } = get().backgroundStatus; + set({ backgroundStatus: rest }); + }, + + replaceTabSessionId: (oldId, realSession, projectName) => { + set((state) => { + const tabs = state.tabs.map((t) => + t.id === oldId + ? { ...t, id: realSession.id, session: realSession, projectName, provider: realSession.__provider || t.provider } + : t, + ); + const activeTabId = state.activeTabId === oldId ? realSession.id : state.activeTabId; + const secondaryTabId = state.secondaryTabId === oldId ? realSession.id : state.secondaryTabId; + + const snapshots = { ...state.snapshots }; + if (snapshots[oldId]) { + snapshots[realSession.id] = snapshots[oldId]; + delete snapshots[oldId]; + } + const backgroundStatus = { ...state.backgroundStatus }; + if (backgroundStatus[oldId]) { + backgroundStatus[realSession.id] = backgroundStatus[oldId]; + delete backgroundStatus[oldId]; + } + return { tabs, activeTabId, secondaryTabId, snapshots, backgroundStatus }; + }); + }, +})); diff --git a/src/types/sessionTabs.ts b/src/types/sessionTabs.ts new file mode 100644 index 00000000..9b25d3ea --- /dev/null +++ b/src/types/sessionTabs.ts @@ -0,0 +1,34 @@ +import type { ChatMessage, TokenBudget } from '../components/chat/types/types'; +import type { ProjectSession, SessionProvider } from './app'; + +export interface SessionTab { + /** Stable identity for this tab slot — never changes, even when the real session + * ID is assigned (replacing the initial `new-session-*` placeholder). Use this + * as the React `key` so the ChatInterface instance stays mounted through ID swaps. */ + tabKey: string; + id: string; + session: ProjectSession; + projectName: string; + provider: SessionProvider; + openedAt: number; +} + +export interface SessionSnapshot { + messages: ChatMessage[]; + isLoading: boolean; + statusText: string | null; + tokenCount: number; + tokenBudget: TokenBudget | null; + scrollTop: number; + canAbort: boolean; +} + +export interface BackgroundSessionStatus { + isLoading: boolean; + statusText: string | null; + tokenCount: number; + /** True when the session completed in the background and hasn't been viewed yet */ + hasUnread: boolean; + /** Incremented every time a background message arrives so UI can react */ + messageSeq: number; +}