From 63e2847fc5474255f7469e6f25cc88ca113baac3 Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:44:47 +0100 Subject: [PATCH] Restore Terminal Session History on App Restart (#1515) * auto-claude: subtask-1-1 - Add debug logging to main process session restoration flow Add debug logging to trace outputBuffer handling in terminal session restoration to help diagnose session history restoration issues: - terminal-lifecycle.ts: Log outputBuffer lengths for passed vs stored sessions, and log buffer preview when returning for replay - terminal-session-store.ts: Log outputBuffer info when getting sessions, updating sessions in memory, migrating from previous dates, and updating output buffer (throttled to avoid spam) Uses debugLog from shared debug-logger utility - only outputs when DEBUG=true. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-1-2 - Add debug logging to renderer restoration flow Add comprehensive debug logging to trace terminal session restoration: - terminal-store.ts: Log restored terminal additions, buffer restoration, session fetching from disk, and restoration completion - usePtyProcess.ts: Log PTY creation/restoration flow including skips, success, and error cases with retry logic - useXterm.ts: Log xterm initialization, buffer replay, output callback registration, dimension ready events, and serialization All logging uses debugLog/debugError from shared utils (only logs when DEBUG=true environment variable is set). * fix(terminal): ensure output buffer is restored before existence check Move terminalBufferManager.set() BEFORE the early return in addRestoredTerminal(). This fixes a bug where terminal chat history was not restored on app restart because: 1. If terminal already existed in store, function returned early 2. Buffer was never stored in terminalBufferManager 3. useXterm read empty buffer and displayed nothing Now the buffer is always restored first, regardless of whether the terminal already exists, ensuring chat history is visible after app restart. Co-Authored-By: Claude Opus 4.5 * fix(terminal): initialize pendingClaudeResume during session restoration Fix Claude resume timing race condition. The TERMINAL_PENDING_RESUME IPC event was sent before the renderer's Terminal component mounted its listener, causing the event to be lost. Now addRestoredTerminal() initializes pendingClaudeResume from session.isClaudeMode, so the renderer knows to trigger 'claude --continue' when the terminal becomes active without relying on IPC timing. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-3-3 - Add visual indicator for terminals with pending Claude resume - Added pendingClaudeResume prop to TerminalHeader component - Visual indicator shows cyan pulsing badge with RotateCcw icon - Badge displays "Resume Available" text (collapses to icon on narrow terminals) - Tooltip explains user can click to resume previous Claude session - Added i18n translations for English and French - Terminal.tsx passes pendingClaudeResume from terminal store to header Co-Authored-By: Claude Opus 4.5 * fix(terminal): address PR review findings - Update pendingClaudeResume for existing terminals during re-restore to ensure deferred Claude resume works in project switch scenarios - Remove sensitive terminal output preview from debug logs - Add atomic getAndClear() method to prevent theoretical buffer data loss between get() and clear() operations Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../src/main/terminal-session-store.ts | 43 ++++++++++++++++++ .../src/main/terminal/terminal-lifecycle.ts | 11 +++++ .../src/renderer/components/Terminal.tsx | 1 + .../components/terminal/TerminalHeader.tsx | 14 +++++- .../components/terminal/usePtyProcess.ts | 28 ++++++++++-- .../renderer/components/terminal/useXterm.ts | 40 ++++++++++++++--- .../renderer/lib/terminal-buffer-manager.ts | 10 +++++ .../src/renderer/stores/terminal-store.ts | 44 ++++++++++++++++--- .../src/shared/i18n/locales/en/terminal.json | 4 ++ .../src/shared/i18n/locales/fr/terminal.json | 4 ++ 10 files changed, 181 insertions(+), 18 deletions(-) diff --git a/apps/frontend/src/main/terminal-session-store.ts b/apps/frontend/src/main/terminal-session-store.ts index cd6d290a..26afbbcd 100644 --- a/apps/frontend/src/main/terminal-session-store.ts +++ b/apps/frontend/src/main/terminal-session-store.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import { join } from 'path'; import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, promises as fsPromises } from 'fs'; import type { TerminalWorktreeConfig } from '../shared/types'; +import { debugLog } from '../shared/utils/debug-logger'; /** * Persisted terminal session data @@ -376,12 +377,24 @@ export class TerminalSessionStore { todaySessions[projectPath] = []; } + // Debug: Log incoming outputBuffer info + const incomingBufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalSessionStore] Updating session in memory:', session.id, + 'incoming outputBuffer:', incomingBufferLen, 'bytes', + 'isClaudeMode:', session.isClaudeMode); + // Update existing or add new const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id); if (existingIndex >= 0) { // Preserve displayOrder from existing session if not provided in incoming session // This prevents periodic saves (which don't include displayOrder) from losing tab order const existingSession = todaySessions[projectPath][existingIndex]; + const existingBufferLen = existingSession.outputBuffer?.length ?? 0; + const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length; + debugLog('[TerminalSessionStore] Updating existing session:', session.id, + 'existing outputBuffer:', existingBufferLen, 'bytes', + 'new outputBuffer (after truncation):', truncatedLen, 'bytes'); + todaySessions[projectPath][existingIndex] = { ...session, // Limit output buffer size @@ -391,6 +404,10 @@ export class TerminalSessionStore { displayOrder: session.displayOrder ?? existingSession.displayOrder, }; } else { + const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length; + debugLog('[TerminalSessionStore] Creating new session:', session.id, + 'outputBuffer (after truncation):', truncatedLen, 'bytes'); + todaySessions[projectPath].push({ ...session, outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER), @@ -437,9 +454,18 @@ export class TerminalSessionStore { getSessions(projectPath: string): TerminalSession[] { const today = getDateString(); + debugLog('[TerminalSessionStore] Getting sessions for project:', projectPath, 'date:', today); + // First check today const todaySessions = this.getTodaysSessions(); if (todaySessions[projectPath]?.length > 0) { + // Debug: Log outputBuffer info for each session + for (const session of todaySessions[projectPath]) { + const bufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalSessionStore] Session', session.id, 'outputBuffer:', bufferLen, 'bytes', + 'isClaudeMode:', session.isClaudeMode, + 'hasBuffer:', bufferLen > 0); + } // Validate worktree configs before returning return todaySessions[projectPath].map(session => ({ ...session, @@ -462,6 +488,15 @@ export class TerminalSessionStore { console.warn(`[TerminalSessionStore] No sessions today, migrating sessions from ${mostRecentDate} to today`); const sessions = this.data.sessionsByDate[mostRecentDate][projectPath] || []; + // Debug: Log outputBuffer info for sessions being migrated + for (const session of sessions) { + const bufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalSessionStore] Migrating session', session.id, 'from', mostRecentDate, + 'outputBuffer:', bufferLen, 'bytes', + 'isClaudeMode:', session.isClaudeMode, + 'hasBuffer:', bufferLen > 0); + } + // MIGRATE: Copy sessions to today's bucket with validated worktree configs const migratedSessions = sessions.map(session => ({ ...session, @@ -629,8 +664,16 @@ export class TerminalSessionStore { const session = sessions.find(s => s.id === sessionId); if (session) { + const prevLen = session.outputBuffer?.length ?? 0; session.outputBuffer = (session.outputBuffer + output).slice(-MAX_OUTPUT_BUFFER); + const newLen = session.outputBuffer.length; session.lastActiveAt = new Date().toISOString(); + + // Debug: Log buffer update (throttled to avoid spam - only log when significant changes) + if (newLen - prevLen > 1000 || prevLen === 0) { + debugLog('[TerminalSessionStore] updateOutputBuffer:', sessionId, + 'prev:', prevLen, 'bytes, added:', output.length, 'bytes, new total:', newLen, 'bytes'); + } // Note: We don't save immediately here to avoid excessive disk writes // Call saveAllPending() periodically or on app quit } diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 423071a4..cdcf1c83 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -149,6 +149,11 @@ export async function restoreTerminal( 'Stored Claude mode:', storedIsClaudeMode, 'Stored session ID:', storedClaudeSessionId); + // Debug: Log outputBuffer info from both passed and stored session + const passedBufferLen = session.outputBuffer?.length ?? 0; + const storedBufferLen = storedSession?.outputBuffer?.length ?? 0; + debugLog('[TerminalLifecycle] OutputBuffer info - passed session:', passedBufferLen, 'bytes, stored session:', storedBufferLen, 'bytes'); + // Validate cwd exists - if the directory was deleted (e.g., worktree removed), // fall back to project path to prevent shell exit with code 1 let effectiveCwd = session.cwd; @@ -235,6 +240,12 @@ export async function restoreTerminal( } } + // Debug: Log the outputBuffer being returned for replay + const returnBufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalLifecycle] Returning outputBuffer for terminal:', session.id, + 'length:', returnBufferLen, 'bytes', + 'hasContent:', returnBufferLen > 0); + return { success: true, outputBuffer: session.outputBuffer diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index ddbf2277..f7910749 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -439,6 +439,7 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title dragHandleListeners={dragHandleListeners} isExpanded={isExpanded} onToggleExpand={onToggleExpand} + pendingClaudeResume={terminal?.pendingClaudeResume} />
void; + /** Whether this terminal has a pending Claude resume (deferred until tab activated) */ + pendingClaudeResume?: boolean; } export function TerminalHeader({ @@ -64,6 +66,7 @@ export function TerminalHeader({ dragHandleListeners, isExpanded, onToggleExpand, + pendingClaudeResume, }: TerminalHeaderProps) { const { t } = useTranslation(['terminal', 'common']); const backlogTasks = tasks.filter((t) => t.status === 'backlog'); @@ -106,6 +109,15 @@ export function TerminalHeader({ {terminalCount < 4 && Claude} )} + {pendingClaudeResume && ( + + + {terminalCount < 4 && {t('terminal:resume.pending')}} + + )} {isClaudeMode && ( { if (result.success && result.data?.success) { + debugLog(`[usePtyProcess] Successfully restored PTY session for terminal: ${terminalId}`); handleSuccess(); const store = getStore(); store.setTerminalStatus(terminalId, terminalState.isClaudeMode ? 'claude-active' : 'running'); store.updateTerminal(terminalId, { isRestored: false }); onCreated?.(); } else { - handleError(`Error restoring session: ${result.data?.error || result.error}`); + const errorMsg = `Error restoring session: ${result.data?.error || result.error}`; + debugError(`[usePtyProcess] Failed to restore PTY session for terminal: ${terminalId}, error: ${errorMsg}`); + handleError(errorMsg); } }).catch((err) => { + debugError(`[usePtyProcess] Exception restoring PTY session for terminal: ${terminalId}, error:`, err); handleError(err.message); }); } else { // New terminal + debugLog(`[usePtyProcess] Creating new PTY for terminal: ${terminalId}, cwd: ${cwd}, projectPath: ${projectPath}`); window.electronAPI.createTerminal({ id: terminalId, cwd, @@ -199,15 +215,19 @@ export function usePtyProcess({ projectPath, }).then((result) => { if (result.success) { + debugLog(`[usePtyProcess] Successfully created PTY for terminal: ${terminalId}`); handleSuccess(); if (!alreadyRunning) { getStore().setTerminalStatus(terminalId, 'running'); } onCreated?.(); } else { - handleError(result.error || 'Unknown error'); + const errorMsg = result.error || 'Unknown error'; + debugError(`[usePtyProcess] Failed to create PTY for terminal: ${terminalId}, error: ${errorMsg}`); + handleError(errorMsg); } }).catch((err) => { + debugError(`[usePtyProcess] Exception creating PTY for terminal: ${terminalId}, error:`, err); handleError(err.message); }); } diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index 856a32d0..391aeb32 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -5,6 +5,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'; import { SerializeAddon } from '@xterm/addon-serialize'; import { terminalBufferManager } from '../../lib/terminal-buffer-manager'; import { registerOutputCallback, unregisterOutputCallback } from '../../stores/terminal-store'; +import { debugLog, debugError } from '../../../shared/utils/debug-logger'; // Type augmentation for navigator.userAgentData (modern User-Agent Client Hints API) interface NavigatorUAData { @@ -44,7 +45,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Initialize xterm.js UI useEffect(() => { - if (!terminalRef.current || xtermRef.current) return; + if (!terminalRef.current || xtermRef.current) { + debugLog(`[useXterm] Skipping xterm initialization for terminal: ${terminalId} - already initialized or container not ready`); + return; + } + + debugLog(`[useXterm] Initializing xterm for terminal: ${terminalId}`); const xterm = new XTerm({ cursorBlink: true, @@ -242,6 +248,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Call onDimensionsReady once when we have valid dimensions if (!dimensionsReadyCalledRef.current && cols > 0 && rows > 0) { dimensionsReadyCalledRef.current = true; + debugLog(`[useXterm] Dimensions ready for terminal: ${terminalId}, cols: ${cols}, rows: ${rows}`); onDimensionsReady?.(cols, rows); } } else { @@ -255,11 +262,14 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Replay buffered output if this is a remount or restored session // This now includes ANSI codes for proper formatting/colors/prompt - const bufferedOutput = terminalBufferManager.get(terminalId); + // Use atomic getAndClear to prevent race condition where new output could arrive between get() and clear() + const bufferedOutput = terminalBufferManager.getAndClear(terminalId); if (bufferedOutput && bufferedOutput.length > 0) { + debugLog(`[useXterm] Replaying buffered output for terminal: ${terminalId}, buffer size: ${bufferedOutput.length} chars`); xterm.write(bufferedOutput); - // Clear buffer after replay to avoid duplicate output - terminalBufferManager.clear(terminalId); + debugLog(`[useXterm] Buffer replay complete and cleared for terminal: ${terminalId}`); + } else { + debugLog(`[useXterm] No buffered output to replay for terminal: ${terminalId}`); } // Handle terminal input @@ -298,7 +308,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // This allows the global listener to write directly to xterm when terminal is visible useEffect(() => { // Only register if xterm is ready - if (!xtermRef.current) return; + if (!xtermRef.current) { + debugLog(`[useXterm] Skipping output callback registration for terminal: ${terminalId} - xterm not ready`); + return; + } + + debugLog(`[useXterm] Registering output callback for terminal: ${terminalId}`); // Create a write function that writes directly to this xterm instance const writeCallback = (data: string) => { @@ -312,6 +327,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Cleanup: unregister callback when component unmounts return () => { + debugLog(`[useXterm] Unregistering output callback for terminal: ${terminalId}`); unregisterOutputCallback(terminalId); }; }, [terminalId]); @@ -394,19 +410,29 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea const serializeBuffer = useCallback(() => { if (xtermRef.current && serializeAddonRef.current) { try { + debugLog(`[useXterm] Serializing buffer for terminal: ${terminalId}`); const serialized = serializeAddonRef.current.serialize(); if (serialized && serialized.length > 0) { terminalBufferManager.set(terminalId, serialized); + debugLog(`[useXterm] Buffer serialized for terminal: ${terminalId}, size: ${serialized.length} chars`); + } else { + debugLog(`[useXterm] No content to serialize for terminal: ${terminalId}`); } } catch (error) { - console.error('[useXterm] Failed to serialize terminal buffer:', error); + debugError('[useXterm] Failed to serialize terminal buffer:', error); } + } else { + debugLog(`[useXterm] Cannot serialize buffer for terminal: ${terminalId} - xterm or serializeAddon not available`); } }, [terminalId]); const dispose = useCallback(() => { // Guard against double dispose (can happen in React StrictMode or rapid unmount) - if (isDisposedRef.current) return; + if (isDisposedRef.current) { + debugLog(`[useXterm] Skipping dispose for terminal: ${terminalId} - already disposed`); + return; + } + debugLog(`[useXterm] Disposing xterm for terminal: ${terminalId}`); isDisposedRef.current = true; // Serialize buffer before disposing to preserve ANSI formatting diff --git a/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts b/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts index 13fc9953..29170614 100644 --- a/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts +++ b/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts @@ -65,6 +65,16 @@ class TerminalBufferManager { this.buffers.delete(id); } + /** + * Atomically get and clear a terminal's buffer + * This prevents race conditions where data could be appended between get() and clear() + */ + getAndClear(id: string): string { + const buffer = this.buffers.get(id) || ''; + this.buffers.delete(id); + return buffer; + } + /** * Check if a terminal has a buffer */ diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 8b156371..ba2314bc 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -192,10 +192,36 @@ export const useTerminalStore = create((set, get) => ({ addRestoredTerminal: (session: TerminalSession) => { const state = get(); + debugLog(`[TerminalStore] addRestoredTerminal called for session: ${session.id}, title: "${session.title}", projectPath: ${session.projectPath}`); + + // CRITICAL: Always restore buffer to buffer manager FIRST, even if terminal already exists. + // This ensures useXterm can replay the buffer regardless of whether this is a fresh restore + // or a re-restore (e.g., after project switch). The buffer must be available before + // the Terminal component mounts and useXterm tries to read it. + if (session.outputBuffer) { + terminalBufferManager.set(session.id, session.outputBuffer); + debugLog(`[TerminalStore] Restored buffer for terminal ${session.id}, size: ${session.outputBuffer.length} chars`); + } else { + debugLog(`[TerminalStore] No output buffer to restore for terminal ${session.id}`); + } // Check if terminal already exists const existingTerminal = state.terminals.find(t => t.id === session.id); if (existingTerminal) { + debugLog(`[TerminalStore] Terminal ${session.id} already exists in store, returning existing (buffer was still restored above)`); + + // If session was in Claude mode before shutdown, update pendingClaudeResume for re-restore scenarios + // (e.g., after project switch). This ensures the deferred resume logic can trigger even when + // the terminal already exists in the store. + if (session.isClaudeMode === true && !existingTerminal.pendingClaudeResume) { + debugLog(`[TerminalStore] Updating pendingClaudeResume for existing terminal ${session.id}`); + set((state) => ({ + terminals: state.terminals.map(t => + t.id === session.id ? { ...t, pendingClaudeResume: true } : t + ) + })); + } + return existingTerminal; } @@ -214,25 +240,26 @@ export const useTerminalStore = create((set, get) => ({ // Keep claudeSessionId so users can resume by clicking the invoke button isClaudeMode: false, claudeSessionId: session.claudeSessionId, - // outputBuffer now stored in terminalBufferManager + // outputBuffer now stored in terminalBufferManager (done above before existence check) isRestored: true, projectPath: session.projectPath, // Worktree config is validated in main process before restore worktreeConfig: session.worktreeConfig, // Restore displayOrder for tab position persistence (falls back to end if not set) displayOrder: session.displayOrder ?? state.terminals.length, + // If session was in Claude mode before shutdown, mark for deferred resume. + // This ensures the renderer knows to trigger 'claude --continue' when the terminal + // becomes active, without relying on the TERMINAL_PENDING_RESUME IPC event timing + // (which may be sent before the Terminal component mounts its listener). + pendingClaudeResume: session.isClaudeMode === true, }; - // Restore buffer to buffer manager - if (session.outputBuffer) { - terminalBufferManager.set(session.id, session.outputBuffer); - } - set((state) => ({ terminals: [...state.terminals, restoredTerminal], activeTerminalId: state.activeTerminalId || restoredTerminal.id, })); + debugLog(`[TerminalStore] Successfully added restored terminal ${session.id} to store, isRestored: true, claudeSessionId: ${session.claudeSessionId || 'none'}, pendingClaudeResume: ${session.isClaudeMode === true}`); return restoredTerminal; }, @@ -489,10 +516,13 @@ export async function restoreTerminalSessions(projectPath: string): Promise