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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { X, Sparkles, TerminalSquare, FolderGit, ExternalLink, GripVertical, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { X, Sparkles, TerminalSquare, FolderGit, ExternalLink, GripVertical, Maximize2, Minimize2, RotateCcw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import type { Task, TerminalWorktreeConfig } from '../../../shared/types';
|
||||
@@ -40,6 +40,8 @@ interface TerminalHeaderProps {
|
||||
isExpanded?: boolean;
|
||||
/** Callback to toggle expanded state */
|
||||
onToggleExpand?: () => 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 && <span>Claude</span>}
|
||||
</span>
|
||||
)}
|
||||
{pendingClaudeResume && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-cyan-500 bg-cyan-500/10 px-1.5 py-0.5 rounded animate-pulse"
|
||||
title={t('terminal:resume.pendingTooltip')}
|
||||
>
|
||||
<RotateCcw className="h-2.5 w-2.5" />
|
||||
{terminalCount < 4 && <span>{t('terminal:resume.pending')}</span>}
|
||||
</span>
|
||||
)}
|
||||
{isClaudeMode && (
|
||||
<TaskSelector
|
||||
terminalId={terminalId}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback, useState, type RefObject } from 'react';
|
||||
import { useTerminalStore } from '../../stores/terminal-store';
|
||||
import { debugLog, debugError } from '../../../shared/utils/debug-logger';
|
||||
|
||||
// Maximum retry attempts for recreation when dimensions aren't ready
|
||||
// Increased from 10 to 30 (3 seconds total) to handle slow app startup scenarios
|
||||
@@ -116,13 +117,20 @@ export function usePtyProcess({
|
||||
|
||||
// During recreation, if dimensions aren't ready, schedule a retry instead of giving up
|
||||
if (skipCreation && isRecreatingRef?.current) {
|
||||
debugLog(`[usePtyProcess] Skipping PTY creation for terminal: ${terminalId} - dimensions not ready during recreation, scheduling retry`);
|
||||
scheduleRetryOrFail('Terminal recreation failed: dimensions not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal skip (not during recreation) - just return
|
||||
if (skipCreation) return;
|
||||
if (isCreatingRef.current || isCreatedRef.current) return;
|
||||
if (skipCreation) {
|
||||
debugLog(`[usePtyProcess] Skipping PTY creation for terminal: ${terminalId} - dimensions not ready`);
|
||||
return;
|
||||
}
|
||||
if (isCreatingRef.current || isCreatedRef.current) {
|
||||
debugLog(`[usePtyProcess] Skipping PTY creation for terminal: ${terminalId} - already creating: ${isCreatingRef.current}, already created: ${isCreatedRef.current}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear retry counter since we're proceeding with creation
|
||||
recreationRetryCountRef.current = 0;
|
||||
@@ -132,6 +140,8 @@ export function usePtyProcess({
|
||||
const alreadyRunning = terminalState?.status === 'running' || terminalState?.status === 'claude-active';
|
||||
const isRestored = terminalState?.isRestored;
|
||||
|
||||
debugLog(`[usePtyProcess] Starting PTY creation for terminal: ${terminalId}, isRestored: ${isRestored}, status: ${terminalState?.status}, cols: ${cols}, rows: ${rows}`);
|
||||
|
||||
// When recreating (e.g., worktree switching), reset status from 'exited' to 'idle'
|
||||
// This allows proper recreation after deliberate terminal destruction
|
||||
if (isRecreatingRef?.current && terminalState?.status === 'exited') {
|
||||
@@ -160,6 +170,7 @@ export function usePtyProcess({
|
||||
|
||||
if (isRestored && terminalState) {
|
||||
// Restored session
|
||||
debugLog(`[usePtyProcess] Restoring session for terminal: ${terminalId}, cwd: ${terminalState.cwd}, isClaudeMode: ${terminalState.isClaudeMode}, claudeSessionId: ${terminalState.claudeSessionId || 'none'}`);
|
||||
window.electronAPI.restoreTerminalSession(
|
||||
{
|
||||
id: terminalState.id,
|
||||
@@ -178,19 +189,24 @@ export function usePtyProcess({
|
||||
rows
|
||||
).then((result) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -192,10 +192,36 @@ export const useTerminalStore = create<TerminalState>((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<TerminalState>((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<void
|
||||
}
|
||||
|
||||
// Restore from disk
|
||||
debugLog(`[TerminalStore] Fetching terminal sessions from disk for project: ${projectPath}`);
|
||||
const result = await window.electronAPI.getTerminalSessions(projectPath);
|
||||
if (!result.success || !result.data || result.data.length === 0) {
|
||||
debugLog(`[TerminalStore] No sessions found on disk for project: ${projectPath}, success: ${result.success}, sessionCount: ${result.data?.length || 0}`);
|
||||
return;
|
||||
}
|
||||
debugLog(`[TerminalStore] Found ${result.data.length} sessions on disk for project: ${projectPath}`);
|
||||
|
||||
// Sort sessions by displayOrder before restoring (lower = further left)
|
||||
// Sessions without displayOrder are placed at the end
|
||||
@@ -503,11 +533,13 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
|
||||
});
|
||||
|
||||
// Add terminals to the store in correct order (they'll be created in the TerminalGrid component)
|
||||
debugLog(`[TerminalStore] Adding ${sortedSessions.length} sorted sessions to store`);
|
||||
for (const session of sortedSessions) {
|
||||
store.addRestoredTerminal(session);
|
||||
}
|
||||
|
||||
store.setHasRestoredSessions(true);
|
||||
debugLog(`[TerminalStore] Completed terminal session restoration for project: ${projectPath}`);
|
||||
} catch (error) {
|
||||
debugError('[TerminalStore] Error restoring sessions:', error);
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"expand": "Expand terminal",
|
||||
"collapse": "Collapse terminal"
|
||||
},
|
||||
"resume": {
|
||||
"pending": "Resume Available",
|
||||
"pendingTooltip": "Click to resume previous Claude session"
|
||||
},
|
||||
"auth": {
|
||||
"terminalTitle": "Auth: {{profileName}}",
|
||||
"maxTerminalsReached": "Cannot open auth terminal: maximum terminals reached. Close a terminal first."
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"expand": "Agrandir le terminal",
|
||||
"collapse": "Reduire le terminal"
|
||||
},
|
||||
"resume": {
|
||||
"pending": "Reprise disponible",
|
||||
"pendingTooltip": "Cliquez pour reprendre la session Claude précédente"
|
||||
},
|
||||
"auth": {
|
||||
"terminalTitle": "Auth: {{profileName}}",
|
||||
"maxTerminalsReached": "Impossible d'ouvrir le terminal d'auth: nombre maximum de terminaux atteint. Fermez un terminal d'abord."
|
||||
|
||||
Reference in New Issue
Block a user