Merge pull request #1819 from AndyMik90/auto-claude/229-implement-account-aware-terminal-session-persisten
auto-claude: 229-implement-account-aware-terminal-session-persisten
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
* and can be copied between profiles to enable session continuity after profile switches.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, copyFileSync, cpSync, unlinkSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir, copyFile, cp, unlink } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { isNodeError } from '../utils/type-guards';
|
||||
@@ -95,12 +96,12 @@ export interface SessionMigrationResult {
|
||||
* @param sessionId - The session UUID to migrate
|
||||
* @returns Migration result with success status and details
|
||||
*/
|
||||
export function migrateSession(
|
||||
export async function migrateSession(
|
||||
sourceConfigDir: string,
|
||||
targetConfigDir: string,
|
||||
cwd: string,
|
||||
sessionId: string
|
||||
): SessionMigrationResult {
|
||||
): Promise<SessionMigrationResult> {
|
||||
const result: SessionMigrationResult = {
|
||||
success: false,
|
||||
sessionId,
|
||||
@@ -118,13 +119,14 @@ export function migrateSession(
|
||||
try {
|
||||
// Ensure target directory exists (do this first, before any file operations)
|
||||
const targetParentDir = dirname(targetFile);
|
||||
mkdirSync(targetParentDir, { recursive: true });
|
||||
await mkdir(targetParentDir, { recursive: true });
|
||||
console.warn('[SessionUtils] Ensured target directory exists:', targetParentDir);
|
||||
|
||||
// Attempt to copy the session .jsonl file
|
||||
// This will throw if source doesn't exist or target cannot be written
|
||||
// Note: copyFile silently overwrites by default (no COPYFILE_EXCL flag)
|
||||
try {
|
||||
copyFileSync(sourceFile, targetFile);
|
||||
await copyFile(sourceFile, targetFile);
|
||||
result.filesCopied++;
|
||||
console.warn('[SessionUtils] Copied session file:', sourceFile, '->', targetFile);
|
||||
} catch (copyError) {
|
||||
@@ -132,12 +134,6 @@ export function migrateSession(
|
||||
if (isNodeError(copyError)) {
|
||||
if (copyError.code === 'ENOENT') {
|
||||
result.error = `Source session file not found: ${sourceFile}`;
|
||||
} else if (copyError.code === 'EEXIST') {
|
||||
// Target already exists - this is OK, treat as successful skip
|
||||
console.warn('[SessionUtils] Session already exists in target profile, skipping copy');
|
||||
result.success = true;
|
||||
result.filesCopied = 0;
|
||||
return result;
|
||||
} else {
|
||||
result.error = `Failed to copy session file: ${copyError.message}`;
|
||||
}
|
||||
@@ -153,7 +149,7 @@ export function migrateSession(
|
||||
// Attempt to copy the session directory (tool-results) if it exists
|
||||
// Use try-catch instead of existsSync to avoid TOCTOU race
|
||||
try {
|
||||
cpSync(sourceDir, targetDir, { recursive: true });
|
||||
await cp(sourceDir, targetDir, { recursive: true });
|
||||
result.filesCopied++;
|
||||
console.warn('[SessionUtils] Copied session directory:', sourceDir, '->', targetDir);
|
||||
} catch (dirCopyError) {
|
||||
@@ -182,7 +178,7 @@ export function migrateSession(
|
||||
// Clean up partially migrated session file to enable retry
|
||||
// Use try-catch instead of existsSync to avoid TOCTOU race
|
||||
try {
|
||||
unlinkSync(targetFile);
|
||||
await unlink(targetFile);
|
||||
console.warn('[SessionUtils] Cleaned up partial migration file:', targetFile);
|
||||
} catch (cleanupError) {
|
||||
// If file doesn't exist during cleanup, that's fine
|
||||
|
||||
@@ -252,6 +252,8 @@ export function registerTerminalHandlers(
|
||||
id: string;
|
||||
sessionId?: string;
|
||||
sessionMigrated?: boolean;
|
||||
isClaudeMode?: boolean;
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
}> = [];
|
||||
|
||||
// Process each terminal
|
||||
@@ -273,7 +275,7 @@ export function registerTerminalHandlers(
|
||||
to: targetConfigDir
|
||||
});
|
||||
|
||||
const migrationResult = migrateSession(
|
||||
const migrationResult = await migrateSession(
|
||||
sourceConfigDir,
|
||||
targetConfigDir,
|
||||
terminal.cwd,
|
||||
@@ -284,11 +286,19 @@ export function registerTerminalHandlers(
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Session migration result:', migrationResult);
|
||||
}
|
||||
|
||||
// Store YOLO mode flag server-side for migrated sessions
|
||||
// (consumed by resumeClaudeAsync when the new terminal resumes)
|
||||
if (sessionMigrated && terminal.claudeSessionId && terminal.dangerouslySkipPermissions) {
|
||||
terminalManager.storeMigratedSessionFlag(terminal.claudeSessionId, terminal.dangerouslySkipPermissions);
|
||||
}
|
||||
|
||||
// All terminals need refresh (PTY env vars can't be updated)
|
||||
terminalsNeedingRefresh.push({
|
||||
id: terminal.id,
|
||||
sessionId: terminal.claudeSessionId,
|
||||
sessionMigrated
|
||||
sessionMigrated,
|
||||
isClaudeMode: terminal.isClaudeMode,
|
||||
dangerouslySkipPermissions: terminal.dangerouslySkipPermissions
|
||||
});
|
||||
}
|
||||
|
||||
@@ -613,9 +623,9 @@ export function registerTerminalHandlers(
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_RESUME_CLAUDE,
|
||||
(_, id: string, sessionId?: string) => {
|
||||
(_, id: string, sessionId?: string, options?: { migratedSession?: boolean }) => {
|
||||
// Use async version to avoid blocking main process during CLI detection
|
||||
terminalManager.resumeClaudeAsync(id, sessionId).catch((error) => {
|
||||
terminalManager.resumeClaudeAsync(id, sessionId, options).catch((error) => {
|
||||
console.warn('[terminal-handlers] Failed to resume Claude:', error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1211,7 +1211,10 @@ export function resumeClaude(
|
||||
console.warn('[ClaudeIntegration:resumeClaude] sessionId parameter is deprecated and ignored; using claude --continue instead');
|
||||
}
|
||||
|
||||
const command = `${pathPrefix}${escapedClaudeCmd} --continue`;
|
||||
// Preserve YOLO mode flag from terminal's stored state
|
||||
const extraFlags = terminal.dangerouslySkipPermissions ? YOLO_MODE_FLAG : '';
|
||||
|
||||
const command = `${pathPrefix}${escapedClaudeCmd} --continue${extraFlags}`;
|
||||
|
||||
// Use PtyManager.writeToPty for safer write with error handling
|
||||
PtyManager.writeToPty(terminal, `${command}\r`);
|
||||
@@ -1386,7 +1389,8 @@ export async function invokeClaudeAsync(
|
||||
export async function resumeClaudeAsync(
|
||||
terminal: TerminalProcess,
|
||||
sessionId: string | undefined,
|
||||
getWindow: WindowGetter
|
||||
getWindow: WindowGetter,
|
||||
options?: { migratedSession?: boolean }
|
||||
): Promise<void> {
|
||||
// Track terminal state for cleanup on error
|
||||
const wasClaudeMode = terminal.isClaudeMode;
|
||||
@@ -1419,12 +1423,19 @@ export async function resumeClaudeAsync(
|
||||
// and we don't want stale IDs persisting through SessionHandler.persistSessionAsync().
|
||||
terminal.claudeSessionId = undefined;
|
||||
|
||||
// Deprecation warning for callers still passing sessionId
|
||||
if (sessionId) {
|
||||
// Deprecation warning for callers still passing sessionId (skip for migrated sessions)
|
||||
if (sessionId && !options?.migratedSession) {
|
||||
console.warn('[ClaudeIntegration:resumeClaudeAsync] sessionId parameter is deprecated and ignored; using claude --continue instead');
|
||||
}
|
||||
|
||||
const command = `${pathPrefix}${escapedClaudeCmd} --continue`;
|
||||
if (options?.migratedSession) {
|
||||
debugLog('[ClaudeIntegration:resumeClaudeAsync] Post-swap resume for terminal:', terminal.id);
|
||||
}
|
||||
|
||||
// Preserve YOLO mode flag from terminal's stored state
|
||||
const extraFlags = terminal.dangerouslySkipPermissions ? YOLO_MODE_FLAG : '';
|
||||
|
||||
const command = `${pathPrefix}${escapedClaudeCmd} --continue${extraFlags}`;
|
||||
|
||||
// Use PtyManager.writeToPty for safer write with error handling
|
||||
PtyManager.writeToPty(terminal, `${command}\r`);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
TerminalProcess,
|
||||
WindowGetter,
|
||||
TerminalOperationResult,
|
||||
TerminalProfileChangeInfo
|
||||
TerminalProfileChangeInfo,
|
||||
} from './types';
|
||||
import * as PtyManager from './pty-manager';
|
||||
import * as SessionHandler from './session-handler';
|
||||
@@ -26,6 +26,8 @@ export class TerminalManager {
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
private lastNotifiedRateLimitReset: Map<string, string> = new Map();
|
||||
private eventCallbacks: TerminalEventHandler.EventHandlerCallbacks;
|
||||
/** Server-side storage for YOLO mode flags during profile migration (sessionId → flag) */
|
||||
private migratedSessionFlags: Map<string, boolean> = new Map();
|
||||
|
||||
constructor(getWindow: WindowGetter) {
|
||||
this.getWindow = getWindow;
|
||||
@@ -114,6 +116,7 @@ export class TerminalManager {
|
||||
* Kill all terminal processes
|
||||
*/
|
||||
async killAll(): Promise<void> {
|
||||
this.migratedSessionFlags.clear();
|
||||
this.saveTimer = await TerminalLifecycle.destroyAllTerminals(
|
||||
this.terminals,
|
||||
this.saveTimer
|
||||
@@ -223,13 +226,36 @@ export class TerminalManager {
|
||||
/**
|
||||
* Resume Claude in a terminal asynchronously (non-blocking)
|
||||
*/
|
||||
async resumeClaudeAsync(id: string, sessionId?: string): Promise<void> {
|
||||
async resumeClaudeAsync(id: string, sessionId?: string, options?: { migratedSession?: boolean }): Promise<void> {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (!terminal) {
|
||||
// Clean up stale migratedSessionFlags if terminal no longer exists
|
||||
if (options?.migratedSession && sessionId) {
|
||||
this.migratedSessionFlags.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow);
|
||||
// For migrated sessions, restore YOLO mode from server-side storage
|
||||
// (set during profile change in storeMigratedSessionFlag)
|
||||
if (options?.migratedSession && sessionId) {
|
||||
const storedFlag = this.migratedSessionFlags.get(sessionId);
|
||||
if (storedFlag !== undefined) {
|
||||
terminal.dangerouslySkipPermissions = storedFlag;
|
||||
this.migratedSessionFlags.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store YOLO mode flag for a session being migrated during profile swap.
|
||||
* Called from the profile change handler before the renderer recreates terminals.
|
||||
* The flag is consumed by resumeClaudeAsync when the new terminal resumes.
|
||||
*/
|
||||
storeMigratedSessionFlag(sessionId: string, dangerouslySkipPermissions: boolean): void {
|
||||
this.migratedSessionFlags.set(sessionId, dangerouslySkipPermissions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,7 +413,8 @@ export class TerminalManager {
|
||||
projectPath: terminal.projectPath,
|
||||
claudeSessionId: terminal.claudeSessionId,
|
||||
claudeProfileId: terminal.claudeProfileId,
|
||||
isClaudeMode: terminal.isClaudeMode
|
||||
isClaudeMode: terminal.isClaudeMode,
|
||||
dangerouslySkipPermissions: terminal.dangerouslySkipPermissions
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -99,4 +99,5 @@ export interface TerminalProfileChangeInfo {
|
||||
claudeSessionId?: string;
|
||||
claudeProfileId?: string;
|
||||
isClaudeMode: boolean;
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface TerminalAPI {
|
||||
rows?: number
|
||||
) => Promise<IPCResult<import('../../shared/types').TerminalRestoreResult>>;
|
||||
clearTerminalSessions: (projectPath: string) => Promise<IPCResult>;
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string) => void;
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void;
|
||||
activateDeferredClaudeResume: (id: string) => void;
|
||||
getTerminalSessionDates: (projectPath?: string) => Promise<IPCResult<import('../../shared/types').SessionDateInfo[]>>;
|
||||
getTerminalSessionsForDate: (
|
||||
@@ -166,8 +166,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
clearTerminalSessions: (projectPath: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, projectPath),
|
||||
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId),
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId, options),
|
||||
|
||||
activateDeferredClaudeResume: (id: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_ACTIVATE_DEFERRED_RESUME, id),
|
||||
|
||||
@@ -52,10 +52,12 @@ export function useTerminalEvents({
|
||||
const store = useTerminalStore.getState();
|
||||
store.setTerminalStatus(terminalId, 'exited');
|
||||
// Reset Claude mode when terminal exits - the Claude process has ended
|
||||
// Use updateTerminal instead of setClaudeMode to avoid changing status back to 'running'
|
||||
// setTerminalStatus('exited') already sends SHELL_EXITED to XState (which handles
|
||||
// claude_active -> exited transition), so setClaudeMode(false) here only updates Zustand
|
||||
// (its XState guard skips CLAUDE_EXITED since the machine is already in 'exited')
|
||||
const terminal = store.getTerminal(terminalId);
|
||||
if (terminal?.isClaudeMode) {
|
||||
store.updateTerminal(terminalId, { isClaudeMode: false });
|
||||
store.setClaudeMode(terminalId, false);
|
||||
}
|
||||
onExitRef.current?.(exitCode);
|
||||
|
||||
@@ -146,12 +148,12 @@ export function useTerminalEvents({
|
||||
return;
|
||||
}
|
||||
// Reset Claude mode - Claude has exited but terminal is still running
|
||||
// Use updateTerminal to set all Claude-related state at once
|
||||
// Use setClaudeMode which properly sends CLAUDE_EXITED to the XState machine,
|
||||
// then clear residual Claude state separately
|
||||
store.setClaudeMode(terminalId, false);
|
||||
store.updateTerminal(terminalId, {
|
||||
isClaudeMode: false,
|
||||
isClaudeBusy: undefined,
|
||||
claudeSessionId: undefined,
|
||||
status: 'running' // Terminal is still running, just not in Claude mode
|
||||
});
|
||||
console.warn('[Terminal] Claude exited, reset mode for terminal:', terminalId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from './use-toast';
|
||||
import { useTerminalStore } from '../stores/terminal-store';
|
||||
import { terminalBufferManager } from '../lib/terminal-buffer-manager';
|
||||
import type { TerminalProfileChangedEvent } from '../../shared/types';
|
||||
@@ -8,16 +10,18 @@ import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
* Hook to handle terminal profile change events.
|
||||
* When a Claude profile switches, all terminals need to be recreated with the new profile's
|
||||
* environment variables. Terminals with active Claude sessions will have their sessions
|
||||
* migrated and can be resumed with --resume {sessionId}.
|
||||
* migrated and automatically resumed with --continue.
|
||||
*/
|
||||
export function useTerminalProfileChange(): void {
|
||||
const { t } = useTranslation(['terminal']);
|
||||
// Track terminals being recreated to prevent duplicate processing
|
||||
const recreatingTerminals = useRef<Set<string>>(new Set());
|
||||
|
||||
const recreateTerminal = useCallback(async (
|
||||
terminalId: string,
|
||||
sessionId?: string,
|
||||
sessionMigrated?: boolean
|
||||
sessionMigrated?: boolean,
|
||||
isClaudeMode?: boolean
|
||||
) => {
|
||||
// Prevent duplicate recreation
|
||||
if (recreatingTerminals.current.has(terminalId)) {
|
||||
@@ -101,25 +105,39 @@ export function useTerminalProfileChange(): void {
|
||||
newId: newTerminal.id
|
||||
});
|
||||
|
||||
// If there was an active Claude session that was migrated, show a message
|
||||
// and set up for potential resume
|
||||
// If there was an active Claude session that was migrated, auto-resume it
|
||||
if (sessionId && sessionMigrated) {
|
||||
debugLog('[useTerminalProfileChange] Session migrated, ready for resume:', sessionId);
|
||||
// Store the session ID so the user can resume if desired
|
||||
debugLog('[useTerminalProfileChange] Session migrated, auto-resuming:', sessionId);
|
||||
// Store the session ID for tracking
|
||||
store.setClaudeSessionId(newTerminal.id, sessionId);
|
||||
// Set pending resume flag - user can trigger resume from terminal tab
|
||||
store.setPendingClaudeResume(newTerminal.id, true);
|
||||
// Send a message to the terminal about the session
|
||||
window.electronAPI.sendTerminalInput(
|
||||
|
||||
// Auto-resume the Claude session with --continue
|
||||
// YOLO mode (dangerouslySkipPermissions) is preserved server-side by the
|
||||
// main process during migration (storeMigratedSessionFlag), so resumeClaudeAsync
|
||||
// will restore it automatically when migratedSession is true
|
||||
// Note: resumeClaudeInTerminal uses fire-and-forget IPC (ipcRenderer.send).
|
||||
// If resume fails in the main process, the error is logged but no failure event
|
||||
// is emitted back to the renderer. The terminal will show an empty shell prompt.
|
||||
window.electronAPI.resumeClaudeInTerminal(
|
||||
newTerminal.id,
|
||||
`# Profile switched. Previous Claude session available.\n# Run: claude --resume ${sessionId}\n`
|
||||
sessionId,
|
||||
{ migratedSession: true }
|
||||
);
|
||||
debugLog('[useTerminalProfileChange] Resume initiated for terminal:', newTerminal.id);
|
||||
} else if (isClaudeMode && sessionId && !sessionMigrated) {
|
||||
// Session had an active Claude session but migration failed
|
||||
// Notify user that their Claude session was lost
|
||||
debugError('[useTerminalProfileChange] Session migration failed for terminal:', terminalId);
|
||||
toast({
|
||||
title: t('terminal:swap.migrationFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
} finally {
|
||||
recreatingTerminals.current.delete(terminalId);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onTerminalProfileChanged(async (event: TerminalProfileChangedEvent) => {
|
||||
@@ -134,7 +152,8 @@ export function useTerminalProfileChange(): void {
|
||||
await recreateTerminal(
|
||||
terminalInfo.id,
|
||||
terminalInfo.sessionId,
|
||||
terminalInfo.sessionMigrated
|
||||
terminalInfo.sessionMigrated,
|
||||
terminalInfo.isClaudeMode
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
import { create } from 'zustand';
|
||||
import { createActor } from 'xstate';
|
||||
import type { ActorRefFrom } from 'xstate';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import type { TerminalSession, TerminalWorktreeConfig } from '../../shared/types';
|
||||
import { terminalMachine, type TerminalEvent } from '@shared/state-machines';
|
||||
import { terminalBufferManager } from '../lib/terminal-buffer-manager';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
type TerminalActor = ActorRefFrom<typeof terminalMachine>;
|
||||
|
||||
/**
|
||||
* Module-level Map to store terminal ID -> XState actor mappings.
|
||||
*
|
||||
* DESIGN NOTE: Stored outside Zustand because actors are mutable references
|
||||
* that shouldn't be serialized in state. Similar pattern to xtermCallbacks.
|
||||
*/
|
||||
const terminalActors = new Map<string, TerminalActor>();
|
||||
|
||||
/**
|
||||
* Get or create an XState terminal actor for a given terminal ID.
|
||||
* Actors are lazily created on first access and cached for the terminal's lifetime.
|
||||
*/
|
||||
export function getOrCreateTerminalActor(terminalId: string): TerminalActor {
|
||||
let actor = terminalActors.get(terminalId);
|
||||
if (!actor) {
|
||||
actor = createActor(terminalMachine);
|
||||
actor.start();
|
||||
terminalActors.set(terminalId, actor);
|
||||
debugLog(`[TerminalStore] Created XState actor for terminal: ${terminalId}`);
|
||||
}
|
||||
return actor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a terminal's XState machine.
|
||||
* Creates the actor if it doesn't exist yet.
|
||||
*/
|
||||
export function sendTerminalMachineEvent(terminalId: string, event: TerminalEvent): void {
|
||||
const actor = getOrCreateTerminalActor(terminalId);
|
||||
const stateBefore = String(actor.getSnapshot().value);
|
||||
actor.send(event);
|
||||
const stateAfter = String(actor.getSnapshot().value);
|
||||
debugLog(`[TerminalStore] Machine ${terminalId}: ${event.type} (${stateBefore} -> ${stateAfter})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level Map to store terminal ID -> xterm write callback mappings.
|
||||
*
|
||||
@@ -301,9 +341,15 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
},
|
||||
|
||||
removeTerminal: (id: string) => {
|
||||
// Clean up buffer manager and output callback
|
||||
// Clean up buffer manager, output callback, and XState actor
|
||||
terminalBufferManager.dispose(id);
|
||||
xtermCallbacks.delete(id);
|
||||
const actor = terminalActors.get(id);
|
||||
if (actor) {
|
||||
actor.stop();
|
||||
terminalActors.delete(id);
|
||||
debugLog(`[TerminalStore] Cleaned up XState actor for terminal: ${id}`);
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newTerminals = state.terminals.filter((t) => t.id !== id);
|
||||
@@ -331,6 +377,13 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
},
|
||||
|
||||
setTerminalStatus: (id: string, status: TerminalStatus) => {
|
||||
// Notify XState machine of lifecycle transitions
|
||||
if (status === 'running') {
|
||||
sendTerminalMachineEvent(id, { type: 'SHELL_READY' });
|
||||
} else if (status === 'exited') {
|
||||
sendTerminalMachineEvent(id, { type: 'SHELL_EXITED' });
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
terminals: state.terminals.map((t) =>
|
||||
t.id === id ? { ...t, status } : t
|
||||
@@ -339,13 +392,32 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
},
|
||||
|
||||
setClaudeMode: (id: string, isClaudeMode: boolean) => {
|
||||
// Send corresponding event to XState machine
|
||||
if (isClaudeMode) {
|
||||
// Ensure machine has transitioned past idle before sending CLAUDE_ACTIVE
|
||||
const actor = getOrCreateTerminalActor(id);
|
||||
if (String(actor.getSnapshot().value) === 'idle') {
|
||||
sendTerminalMachineEvent(id, { type: 'SHELL_READY' });
|
||||
}
|
||||
// Include current claudeSessionId to prevent XState action from overwriting it
|
||||
const terminal = get().terminals.find(t => t.id === id);
|
||||
sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE', claudeSessionId: terminal?.claudeSessionId });
|
||||
} else {
|
||||
// Only send CLAUDE_EXITED if machine is in a state that accepts it
|
||||
const actor = getOrCreateTerminalActor(id);
|
||||
const currentState = String(actor.getSnapshot().value);
|
||||
if (currentState === 'claude_starting' || currentState === 'claude_active') {
|
||||
sendTerminalMachineEvent(id, { type: 'CLAUDE_EXITED' });
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
terminals: state.terminals.map((t) =>
|
||||
t.id === id
|
||||
? {
|
||||
...t,
|
||||
isClaudeMode,
|
||||
status: isClaudeMode ? 'claude-active' : 'running',
|
||||
status: isClaudeMode ? 'claude-active' : (t.status === 'exited' ? 'exited' : 'running'),
|
||||
// Reset busy state and naming flag when leaving Claude mode
|
||||
isClaudeBusy: isClaudeMode ? t.isClaudeBusy : undefined,
|
||||
claudeNamedOnce: isClaudeMode ? t.claudeNamedOnce : undefined
|
||||
@@ -356,6 +428,14 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
},
|
||||
|
||||
setClaudeSessionId: (id: string, sessionId: string) => {
|
||||
// Ensure machine has transitioned past idle before sending CLAUDE_ACTIVE
|
||||
const actor = getOrCreateTerminalActor(id);
|
||||
if (String(actor.getSnapshot().value) === 'idle') {
|
||||
sendTerminalMachineEvent(id, { type: 'SHELL_READY' });
|
||||
}
|
||||
// Send CLAUDE_ACTIVE with session ID to XState machine
|
||||
sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE', claudeSessionId: sessionId });
|
||||
|
||||
set((state) => ({
|
||||
terminals: state.terminals.map((t) =>
|
||||
t.id === id ? { ...t, claudeSessionId: sessionId } : t
|
||||
@@ -380,6 +460,9 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
},
|
||||
|
||||
setClaudeBusy: (id: string, isBusy: boolean) => {
|
||||
// Send CLAUDE_BUSY event to XState machine
|
||||
sendTerminalMachineEvent(id, { type: 'CLAUDE_BUSY', isBusy });
|
||||
|
||||
set((state) => ({
|
||||
terminals: state.terminals.map((t) =>
|
||||
t.id === id ? { ...t, isClaudeBusy: isBusy } : t
|
||||
@@ -388,11 +471,37 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
},
|
||||
|
||||
setPendingClaudeResume: (id: string, pending: boolean) => {
|
||||
set((state) => ({
|
||||
terminals: state.terminals.map((t) =>
|
||||
t.id === id ? { ...t, pendingClaudeResume: pending } : t
|
||||
),
|
||||
}));
|
||||
// Send RESUME_REQUESTED or RESUME_COMPLETE to XState machine
|
||||
let shouldUpdateZustand = true;
|
||||
|
||||
if (pending) {
|
||||
const terminal = get().terminals.find(t => t.id === id);
|
||||
if (terminal?.claudeSessionId) {
|
||||
sendTerminalMachineEvent(id, { type: 'RESUME_REQUESTED', claudeSessionId: terminal.claudeSessionId });
|
||||
} else {
|
||||
// No claudeSessionId - can't send RESUME_REQUESTED, so don't set pendingClaudeResume
|
||||
// to avoid XState/Zustand divergence (UI would show pending but machine wouldn't know)
|
||||
debugLog('[terminal-store] setPendingClaudeResume: dropping request for terminal', id, '- no claudeSessionId');
|
||||
shouldUpdateZustand = false;
|
||||
}
|
||||
} else {
|
||||
// Resume cleared - either completed or cancelled
|
||||
const actor = terminalActors.get(id);
|
||||
if (actor && String(actor.getSnapshot().value) === 'pending_resume') {
|
||||
// Include claudeSessionId to prevent XState action from overwriting it to undefined
|
||||
const terminal = get().terminals.find(t => t.id === id);
|
||||
sendTerminalMachineEvent(id, { type: 'RESUME_COMPLETE', claudeSessionId: terminal?.claudeSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
// Only update Zustand state if XState was notified (prevents state divergence)
|
||||
if (shouldUpdateZustand) {
|
||||
set((state) => ({
|
||||
terminals: state.terminals.map((t) =>
|
||||
t.id === id ? { ...t, pendingClaudeResume: pending } : t
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
setClaudeNamedOnce: (id: string, named: boolean) => {
|
||||
@@ -404,6 +513,18 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearAllTerminals: () => {
|
||||
// Clean up all resources for every terminal
|
||||
const terminals = get().terminals;
|
||||
for (const terminal of terminals) {
|
||||
terminalBufferManager.dispose(terminal.id);
|
||||
xtermCallbacks.delete(terminal.id);
|
||||
}
|
||||
|
||||
// Clean up all XState actors
|
||||
for (const [_id, actor] of terminalActors) {
|
||||
actor.stop();
|
||||
}
|
||||
terminalActors.clear();
|
||||
set({ terminals: [], activeTerminalId: null, hasRestoredSessions: false });
|
||||
},
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"terminalTitle": "Auth: {{profileName}}",
|
||||
"maxTerminalsReached": "Cannot open auth terminal: maximum terminals reached. Close a terminal first."
|
||||
},
|
||||
"swap": {
|
||||
"inProgress": "Switching profile...",
|
||||
"resumingSession": "Resuming Claude session...",
|
||||
"sessionResumed": "Session resumed under new profile",
|
||||
"resumeFailed": "Could not resume session. You can start a new session.",
|
||||
"noSession": "Profile switched. No active session to resume.",
|
||||
"migrationFailed": "Profile switched, but session migration failed. Starting fresh terminal."
|
||||
},
|
||||
"worktree": {
|
||||
"create": "Worktree",
|
||||
"createNew": "New Worktree",
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"terminalTitle": "Auth: {{profileName}}",
|
||||
"maxTerminalsReached": "Impossible d'ouvrir le terminal d'auth: nombre maximum de terminaux atteint. Fermez un terminal d'abord."
|
||||
},
|
||||
"swap": {
|
||||
"inProgress": "Changement de profil...",
|
||||
"resumingSession": "Reprise de la session Claude...",
|
||||
"sessionResumed": "Session reprise sous le nouveau profil",
|
||||
"resumeFailed": "Impossible de reprendre la session. Vous pouvez démarrer une nouvelle session.",
|
||||
"noSession": "Profil changé. Aucune session active à reprendre.",
|
||||
"migrationFailed": "Profil changé, mais la migration de session a échoué. Démarrage d'un nouveau terminal."
|
||||
},
|
||||
"worktree": {
|
||||
"create": "Worktree",
|
||||
"createNew": "Nouveau Worktree",
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createActor } from 'xstate';
|
||||
import { terminalMachine, type TerminalEvent, type TerminalContext } from '../terminal-machine';
|
||||
|
||||
/**
|
||||
* Helper to run a sequence of events and get the final snapshot.
|
||||
* Optionally starts from a restored state with given context.
|
||||
*/
|
||||
function runEvents(
|
||||
events: TerminalEvent[],
|
||||
initialState?: string,
|
||||
initialContext?: Partial<TerminalContext>
|
||||
) {
|
||||
const actor = initialState
|
||||
? createActor(terminalMachine, {
|
||||
snapshot: terminalMachine.resolveState({
|
||||
value: initialState,
|
||||
context: {
|
||||
claudeSessionId: undefined,
|
||||
profileId: undefined,
|
||||
swapTargetProfileId: undefined,
|
||||
swapPhase: undefined,
|
||||
isBusy: false,
|
||||
error: undefined,
|
||||
...initialContext,
|
||||
},
|
||||
}),
|
||||
})
|
||||
: createActor(terminalMachine);
|
||||
actor.start();
|
||||
|
||||
for (const event of events) {
|
||||
actor.send(event);
|
||||
}
|
||||
|
||||
const snapshot = actor.getSnapshot();
|
||||
actor.stop();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe('terminalMachine', () => {
|
||||
describe('initial state', () => {
|
||||
it('should start in idle state', () => {
|
||||
const actor = createActor(terminalMachine);
|
||||
actor.start();
|
||||
expect(actor.getSnapshot().value).toBe('idle');
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it('should have default context initially', () => {
|
||||
const actor = createActor(terminalMachine);
|
||||
actor.start();
|
||||
const { context } = actor.getSnapshot();
|
||||
expect(context.claudeSessionId).toBeUndefined();
|
||||
expect(context.profileId).toBeUndefined();
|
||||
expect(context.swapTargetProfileId).toBeUndefined();
|
||||
expect(context.swapPhase).toBeUndefined();
|
||||
expect(context.isBusy).toBe(false);
|
||||
expect(context.error).toBeUndefined();
|
||||
actor.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('happy path: idle → shell_ready → claude_active → exited', () => {
|
||||
it('should transition from idle to shell_ready', () => {
|
||||
const snapshot = runEvents([{ type: 'SHELL_READY' }]);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
});
|
||||
|
||||
it('should transition from shell_ready to claude_starting', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('claude_starting');
|
||||
expect(snapshot.context.profileId).toBe('profile-1');
|
||||
});
|
||||
|
||||
it('should transition from shell_ready directly to claude_active on CLAUDE_ACTIVE', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-direct' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('session-direct');
|
||||
});
|
||||
|
||||
it('should transition from claude_starting to claude_active', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
it('should transition from claude_active to shell_ready on CLAUDE_EXITED', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'CLAUDE_EXITED' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
expect(snapshot.context.isBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('should transition to exited on SHELL_EXITED from claude_active', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'SHELL_EXITED', exitCode: 0 },
|
||||
]);
|
||||
expect(snapshot.value).toBe('exited');
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should complete full lifecycle: idle → shell_ready → claude_active → exited', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'SHELL_EXITED' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('exited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('swap flow: claude_active → swapping → claude_active', () => {
|
||||
const toClaudeActive: TerminalEvent[] = [
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
];
|
||||
|
||||
it('should transition to swapping on SWAP_INITIATED with active session', () => {
|
||||
const snapshot = runEvents([
|
||||
...toClaudeActive,
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('swapping');
|
||||
expect(snapshot.context.swapTargetProfileId).toBe('profile-2');
|
||||
expect(snapshot.context.swapPhase).toBe('capturing');
|
||||
});
|
||||
|
||||
it('should progress through swap phases', () => {
|
||||
const snapshot = runEvents([
|
||||
...toClaudeActive,
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
{ type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('swapping');
|
||||
expect(snapshot.context.swapPhase).toBe('migrating');
|
||||
expect(snapshot.context.claudeSessionId).toBe('captured-session');
|
||||
});
|
||||
|
||||
it('should progress to recreating phase', () => {
|
||||
const snapshot = runEvents([
|
||||
...toClaudeActive,
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
{ type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' },
|
||||
{ type: 'SWAP_MIGRATED' },
|
||||
]);
|
||||
expect(snapshot.context.swapPhase).toBe('recreating');
|
||||
});
|
||||
|
||||
it('should progress to resuming phase', () => {
|
||||
const snapshot = runEvents([
|
||||
...toClaudeActive,
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
{ type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' },
|
||||
{ type: 'SWAP_MIGRATED' },
|
||||
{ type: 'SWAP_TERMINAL_RECREATED' },
|
||||
]);
|
||||
expect(snapshot.context.swapPhase).toBe('resuming');
|
||||
});
|
||||
|
||||
it('should return to claude_active after successful swap', () => {
|
||||
const snapshot = runEvents([
|
||||
...toClaudeActive,
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
{ type: 'SWAP_SESSION_CAPTURED', claudeSessionId: 'captured-session' },
|
||||
{ type: 'SWAP_MIGRATED' },
|
||||
{ type: 'SWAP_TERMINAL_RECREATED' },
|
||||
{
|
||||
type: 'SWAP_RESUME_COMPLETE',
|
||||
claudeSessionId: 'new-session',
|
||||
profileId: 'profile-2',
|
||||
},
|
||||
]);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('new-session');
|
||||
expect(snapshot.context.profileId).toBe('profile-2');
|
||||
expect(snapshot.context.swapTargetProfileId).toBeUndefined();
|
||||
expect(snapshot.context.swapPhase).toBeUndefined();
|
||||
expect(snapshot.context.isBusy).toBe(false);
|
||||
expect(snapshot.context.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed swap: swapping → shell_ready with error', () => {
|
||||
const toSwapping: TerminalEvent[] = [
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
];
|
||||
|
||||
it('should transition to shell_ready on SWAP_FAILED', () => {
|
||||
const snapshot = runEvents([
|
||||
...toSwapping,
|
||||
{ type: 'SWAP_FAILED', error: 'Swap error' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
expect(snapshot.context.error).toBe('Swap error');
|
||||
expect(snapshot.context.swapTargetProfileId).toBeUndefined();
|
||||
expect(snapshot.context.swapPhase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should transition to exited on SHELL_EXITED during swap', () => {
|
||||
const snapshot = runEvents([
|
||||
...toSwapping,
|
||||
{ type: 'SHELL_EXITED', exitCode: 1 },
|
||||
]);
|
||||
expect(snapshot.value).toBe('exited');
|
||||
expect(snapshot.context.swapTargetProfileId).toBeUndefined();
|
||||
expect(snapshot.context.swapPhase).toBeUndefined();
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred resume: pending_resume → claude_active', () => {
|
||||
it('should transition from shell_ready to pending_resume on RESUME_REQUESTED', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'RESUME_REQUESTED', claudeSessionId: 'session-1' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('pending_resume');
|
||||
expect(snapshot.context.claudeSessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
it('should transition from claude_active to pending_resume on RESUME_REQUESTED', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'RESUME_REQUESTED', claudeSessionId: 'session-2' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('pending_resume');
|
||||
expect(snapshot.context.claudeSessionId).toBe('session-2');
|
||||
});
|
||||
|
||||
it('should transition to claude_active on RESUME_COMPLETE', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'RESUME_COMPLETE', claudeSessionId: 'resumed-session' }],
|
||||
'pending_resume',
|
||||
{ claudeSessionId: 'old-session', profileId: 'profile-1' }
|
||||
);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('resumed-session');
|
||||
});
|
||||
|
||||
it('should transition to shell_ready on RESUME_FAILED', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'RESUME_FAILED', error: 'Resume failed' }],
|
||||
'pending_resume',
|
||||
{ claudeSessionId: 'old-session', profileId: 'profile-1' }
|
||||
);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
expect(snapshot.context.error).toBe('Resume failed');
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should transition to claude_active on CLAUDE_ACTIVE (race condition)', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'race-session' }],
|
||||
'pending_resume',
|
||||
{ claudeSessionId: 'old-session', profileId: 'profile-1' }
|
||||
);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('race-session');
|
||||
});
|
||||
|
||||
it('should transition to exited on SHELL_EXITED from pending_resume', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'SHELL_EXITED' }],
|
||||
'pending_resume'
|
||||
);
|
||||
expect(snapshot.value).toBe('exited');
|
||||
});
|
||||
|
||||
it('should reset from pending_resume', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'RESET' }],
|
||||
'pending_resume',
|
||||
{ claudeSessionId: 'session-1', profileId: 'profile-1' }
|
||||
);
|
||||
expect(snapshot.value).toBe('idle');
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
expect(snapshot.context.profileId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid transitions rejected', () => {
|
||||
it('should not allow SWAP_INITIATED from idle', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('idle');
|
||||
});
|
||||
|
||||
it('should not allow SWAP_INITIATED from shell_ready', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
});
|
||||
|
||||
it('should not allow CLAUDE_START from idle', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('idle');
|
||||
});
|
||||
|
||||
it('should not allow CLAUDE_ACTIVE from idle', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('idle');
|
||||
});
|
||||
|
||||
it('should not allow SWAP_INITIATED from claude_starting', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('claude_starting');
|
||||
});
|
||||
|
||||
it('should not allow RESUME_COMPLETE from claude_active', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'RESUME_COMPLETE', claudeSessionId: 'session-2' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('session-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAUDE_ACTIVE self-transition in claude_active', () => {
|
||||
it('should update claudeSessionId via self-transition', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_ACTIVE' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'late-session' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('late-session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('context mutations', () => {
|
||||
it('should set profileId on CLAUDE_START', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'my-profile' },
|
||||
]);
|
||||
expect(snapshot.context.profileId).toBe('my-profile');
|
||||
});
|
||||
|
||||
it('should set claudeSessionId on CLAUDE_ACTIVE', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-abc' },
|
||||
]);
|
||||
expect(snapshot.context.claudeSessionId).toBe('session-abc');
|
||||
});
|
||||
|
||||
it('should set isBusy on CLAUDE_BUSY', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'CLAUDE_BUSY', isBusy: true },
|
||||
]);
|
||||
expect(snapshot.context.isBusy).toBe(true);
|
||||
});
|
||||
|
||||
it('should unset isBusy on CLAUDE_BUSY false', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'CLAUDE_BUSY', isBusy: true },
|
||||
{ type: 'CLAUDE_BUSY', isBusy: false },
|
||||
]);
|
||||
expect(snapshot.context.isBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear error on CLAUDE_START', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'CLAUDE_START', profileId: 'profile-1' }],
|
||||
'shell_ready',
|
||||
{ error: 'previous error' }
|
||||
);
|
||||
expect(snapshot.context.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set error on CLAUDE_EXITED with error', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_EXITED', error: 'crash' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
expect(snapshot.context.error).toBe('crash');
|
||||
});
|
||||
|
||||
it('should clear session on CLAUDE_EXITED', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'CLAUDE_EXITED' },
|
||||
]);
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
expect(snapshot.context.isBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('should set error on CLAUDE_EXITED with error from claude_active', () => {
|
||||
const snapshot = runEvents([
|
||||
{ type: 'SHELL_READY' },
|
||||
{ type: 'CLAUDE_START', profileId: 'profile-1' },
|
||||
{ type: 'CLAUDE_ACTIVE', claudeSessionId: 'session-1' },
|
||||
{ type: 'CLAUDE_EXITED', error: 'crash while active' },
|
||||
]);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
expect(snapshot.context.error).toBe('crash while active');
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear error on SHELL_READY from exited', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'SHELL_READY' }],
|
||||
'exited',
|
||||
{ error: 'old error' }
|
||||
);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
expect(snapshot.context.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('guard conditions', () => {
|
||||
it('should not allow SWAP_INITIATED without active session', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }],
|
||||
'claude_active',
|
||||
{ claudeSessionId: undefined }
|
||||
);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
});
|
||||
|
||||
it('should allow SWAP_INITIATED with active session', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'SWAP_INITIATED', targetProfileId: 'profile-2' }],
|
||||
'claude_active',
|
||||
{ claudeSessionId: 'session-1' }
|
||||
);
|
||||
expect(snapshot.value).toBe('swapping');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RESET from all states', () => {
|
||||
const states = [
|
||||
'idle',
|
||||
'shell_ready',
|
||||
'claude_starting',
|
||||
'claude_active',
|
||||
'swapping',
|
||||
'pending_resume',
|
||||
'exited',
|
||||
];
|
||||
|
||||
for (const state of states) {
|
||||
it(`should reset to idle from ${state}`, () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'RESET' }],
|
||||
state,
|
||||
{
|
||||
claudeSessionId: 'session-1',
|
||||
profileId: 'profile-1',
|
||||
isBusy: true,
|
||||
error: 'err',
|
||||
swapTargetProfileId: 'profile-2',
|
||||
swapPhase: 'migrating'
|
||||
}
|
||||
);
|
||||
expect(snapshot.value).toBe('idle');
|
||||
expect(snapshot.context.claudeSessionId).toBeUndefined();
|
||||
expect(snapshot.context.profileId).toBeUndefined();
|
||||
expect(snapshot.context.isBusy).toBe(false);
|
||||
expect(snapshot.context.error).toBeUndefined();
|
||||
expect(snapshot.context.swapTargetProfileId).toBeUndefined();
|
||||
expect(snapshot.context.swapPhase).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('state restoration from snapshot', () => {
|
||||
it('should restore claude_active state with context', () => {
|
||||
const actor = createActor(terminalMachine, {
|
||||
snapshot: terminalMachine.resolveState({
|
||||
value: 'claude_active',
|
||||
context: {
|
||||
claudeSessionId: 'restored-session',
|
||||
profileId: 'restored-profile',
|
||||
swapTargetProfileId: undefined,
|
||||
swapPhase: undefined,
|
||||
isBusy: true,
|
||||
error: undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
actor.start();
|
||||
const snapshot = actor.getSnapshot();
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('restored-session');
|
||||
expect(snapshot.context.profileId).toBe('restored-profile');
|
||||
expect(snapshot.context.isBusy).toBe(true);
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it('should restore swapping state and complete swap', () => {
|
||||
const snapshot = runEvents(
|
||||
[
|
||||
{
|
||||
type: 'SWAP_RESUME_COMPLETE',
|
||||
claudeSessionId: 'new-session',
|
||||
profileId: 'profile-2',
|
||||
},
|
||||
],
|
||||
'swapping',
|
||||
{
|
||||
claudeSessionId: 'old-session',
|
||||
profileId: 'profile-1',
|
||||
swapTargetProfileId: 'profile-2',
|
||||
swapPhase: 'resuming',
|
||||
}
|
||||
);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.profileId).toBe('profile-2');
|
||||
expect(snapshot.context.claudeSessionId).toBe('new-session');
|
||||
});
|
||||
|
||||
it('should restore pending_resume and complete resume', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'RESUME_COMPLETE', claudeSessionId: 'resumed-session' }],
|
||||
'pending_resume',
|
||||
{ claudeSessionId: 'stale-session', profileId: 'profile-1' }
|
||||
);
|
||||
expect(snapshot.value).toBe('claude_active');
|
||||
expect(snapshot.context.claudeSessionId).toBe('resumed-session');
|
||||
});
|
||||
|
||||
it('should restore exited state and restart', () => {
|
||||
const snapshot = runEvents(
|
||||
[{ type: 'SHELL_READY' }],
|
||||
'exited'
|
||||
);
|
||||
expect(snapshot.value).toBe('shell_ready');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ export {
|
||||
} from './task-state-utils';
|
||||
export type { TaskStateName } from './task-state-utils';
|
||||
|
||||
export { terminalMachine } from './terminal-machine';
|
||||
export type { TerminalContext, TerminalEvent } from './terminal-machine';
|
||||
|
||||
export { roadmapGenerationMachine } from './roadmap-generation-machine';
|
||||
export type {
|
||||
RoadmapGenerationContext,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { assign, createMachine } from 'xstate';
|
||||
|
||||
/**
|
||||
* Terminal lifecycle state machine context.
|
||||
*
|
||||
* Tracks Claude Code session state, profile swap progress,
|
||||
* and error information for a single terminal instance.
|
||||
*/
|
||||
export interface TerminalContext {
|
||||
claudeSessionId?: string;
|
||||
profileId?: string;
|
||||
swapTargetProfileId?: string;
|
||||
swapPhase?: 'capturing' | 'migrating' | 'recreating' | 'resuming';
|
||||
isBusy: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all terminal lifecycle events.
|
||||
*/
|
||||
export type TerminalEvent =
|
||||
| { type: 'SHELL_READY' }
|
||||
| { type: 'CLAUDE_START'; profileId: string }
|
||||
| { type: 'CLAUDE_ACTIVE'; claudeSessionId?: string }
|
||||
| { type: 'CLAUDE_BUSY'; isBusy: boolean }
|
||||
| { type: 'CLAUDE_EXITED'; exitCode?: number; error?: string }
|
||||
| { type: 'SWAP_INITIATED'; targetProfileId: string }
|
||||
| { type: 'SWAP_SESSION_CAPTURED'; claudeSessionId: string }
|
||||
| { type: 'SWAP_MIGRATED' }
|
||||
| { type: 'SWAP_TERMINAL_RECREATED' }
|
||||
| { type: 'SWAP_RESUME_COMPLETE'; claudeSessionId?: string; profileId: string }
|
||||
| { type: 'SWAP_FAILED'; error: string }
|
||||
| { type: 'RESUME_REQUESTED'; claudeSessionId: string }
|
||||
| { type: 'RESUME_COMPLETE'; claudeSessionId?: string }
|
||||
| { type: 'RESUME_FAILED'; error: string }
|
||||
| { type: 'SHELL_EXITED'; exitCode?: number; signal?: string }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export const terminalMachine = createMachine(
|
||||
{
|
||||
id: 'terminal',
|
||||
initial: 'idle',
|
||||
types: {} as {
|
||||
context: TerminalContext;
|
||||
events: TerminalEvent;
|
||||
},
|
||||
context: {
|
||||
claudeSessionId: undefined,
|
||||
profileId: undefined,
|
||||
swapTargetProfileId: undefined,
|
||||
swapPhase: undefined,
|
||||
isBusy: false,
|
||||
error: undefined,
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
SHELL_READY: 'shell_ready',
|
||||
RESET: { target: 'idle', actions: 'resetContext' },
|
||||
},
|
||||
},
|
||||
shell_ready: {
|
||||
on: {
|
||||
CLAUDE_START: { target: 'claude_starting', actions: 'setProfileId' },
|
||||
CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' },
|
||||
RESUME_REQUESTED: { target: 'pending_resume', actions: 'setClaudeSessionId' },
|
||||
SHELL_EXITED: { target: 'exited', actions: 'clearSession' },
|
||||
RESET: { target: 'idle', actions: 'resetContext' },
|
||||
},
|
||||
},
|
||||
claude_starting: {
|
||||
on: {
|
||||
CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' },
|
||||
CLAUDE_BUSY: { actions: 'setBusy' },
|
||||
CLAUDE_EXITED: { target: 'shell_ready', actions: ['setError', 'clearSession'] },
|
||||
SHELL_EXITED: { target: 'exited', actions: 'clearSession' },
|
||||
RESET: { target: 'idle', actions: 'resetContext' },
|
||||
},
|
||||
},
|
||||
claude_active: {
|
||||
on: {
|
||||
CLAUDE_ACTIVE: { actions: 'updateClaudeSessionId' },
|
||||
CLAUDE_BUSY: { actions: 'setBusy' },
|
||||
CLAUDE_EXITED: { target: 'shell_ready', actions: ['setError', 'clearSession'] },
|
||||
SWAP_INITIATED: {
|
||||
target: 'swapping',
|
||||
guard: 'hasActiveSession',
|
||||
actions: 'setSwapTarget',
|
||||
},
|
||||
RESUME_REQUESTED: { target: 'pending_resume', actions: 'setClaudeSessionId' },
|
||||
SHELL_EXITED: { target: 'exited', actions: 'clearSession' },
|
||||
RESET: { target: 'idle', actions: 'resetContext' },
|
||||
},
|
||||
},
|
||||
swapping: {
|
||||
on: {
|
||||
SWAP_SESSION_CAPTURED: {
|
||||
guard: 'isCapturingPhase',
|
||||
actions: ['setCapturedSession', 'setSwapPhaseMigrating'],
|
||||
},
|
||||
SWAP_MIGRATED: {
|
||||
guard: 'isMigratingPhase',
|
||||
actions: 'setSwapPhaseRecreating',
|
||||
},
|
||||
SWAP_TERMINAL_RECREATED: {
|
||||
guard: 'isRecreatingPhase',
|
||||
actions: 'setSwapPhaseResuming',
|
||||
},
|
||||
SWAP_RESUME_COMPLETE: {
|
||||
target: 'claude_active',
|
||||
guard: 'isResumingPhase',
|
||||
actions: 'applySwapComplete',
|
||||
},
|
||||
SWAP_FAILED: {
|
||||
target: 'shell_ready',
|
||||
actions: ['setError', 'clearSwapState'],
|
||||
},
|
||||
SHELL_EXITED: { target: 'exited', actions: ['clearSession', 'clearSwapState'] },
|
||||
RESET: { target: 'idle', actions: 'resetContext' },
|
||||
},
|
||||
},
|
||||
pending_resume: {
|
||||
on: {
|
||||
CLAUDE_ACTIVE: { target: 'claude_active', actions: 'setClaudeSessionId' },
|
||||
CLAUDE_BUSY: { actions: 'setBusy' },
|
||||
RESUME_COMPLETE: { target: 'claude_active', actions: 'setClaudeSessionId' },
|
||||
RESUME_FAILED: { target: 'shell_ready', actions: ['setError', 'clearSession'] },
|
||||
SHELL_EXITED: { target: 'exited', actions: 'clearSession' },
|
||||
RESET: { target: 'idle', actions: 'resetContext' },
|
||||
},
|
||||
},
|
||||
exited: {
|
||||
on: {
|
||||
SHELL_READY: { target: 'shell_ready', actions: 'clearError' },
|
||||
RESET: { target: 'idle', actions: 'resetContext' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
guards: {
|
||||
hasActiveSession: ({ context }) => context.claudeSessionId !== undefined,
|
||||
isCapturingPhase: ({ context }) => context.swapPhase === 'capturing',
|
||||
isMigratingPhase: ({ context }) => context.swapPhase === 'migrating',
|
||||
isRecreatingPhase: ({ context }) => context.swapPhase === 'recreating',
|
||||
isResumingPhase: ({ context }) => context.swapPhase === 'resuming',
|
||||
},
|
||||
actions: {
|
||||
setProfileId: assign({
|
||||
profileId: ({ event }) =>
|
||||
event.type === 'CLAUDE_START' ? event.profileId : undefined,
|
||||
error: () => undefined,
|
||||
}),
|
||||
setClaudeSessionId: assign({
|
||||
claudeSessionId: ({ event }) => {
|
||||
if (event.type === 'CLAUDE_ACTIVE') return event.claudeSessionId;
|
||||
if (event.type === 'RESUME_COMPLETE') return event.claudeSessionId;
|
||||
if (event.type === 'RESUME_REQUESTED') return event.claudeSessionId;
|
||||
return undefined;
|
||||
},
|
||||
// Clear isBusy when entering claude_active from another state
|
||||
isBusy: () => false,
|
||||
error: () => undefined,
|
||||
}),
|
||||
// Self-transition action for CLAUDE_ACTIVE within claude_active state:
|
||||
// Updates sessionId but preserves isBusy (avoids resetting busy indicator
|
||||
// when the session ID is refreshed without a state change)
|
||||
updateClaudeSessionId: assign({
|
||||
claudeSessionId: ({ event }) =>
|
||||
event.type === 'CLAUDE_ACTIVE' ? event.claudeSessionId : undefined,
|
||||
error: () => undefined,
|
||||
}),
|
||||
setBusy: assign({
|
||||
isBusy: ({ event }) =>
|
||||
event.type === 'CLAUDE_BUSY' ? event.isBusy : false,
|
||||
}),
|
||||
setError: assign({
|
||||
error: ({ event }) => {
|
||||
if (event.type === 'CLAUDE_EXITED') return event.error;
|
||||
if (event.type === 'SWAP_FAILED') return event.error;
|
||||
if (event.type === 'RESUME_FAILED') return event.error;
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
clearError: assign({ error: () => undefined }),
|
||||
clearSession: assign({
|
||||
claudeSessionId: () => undefined,
|
||||
isBusy: () => false,
|
||||
}),
|
||||
setSwapTarget: assign({
|
||||
swapTargetProfileId: ({ event }) =>
|
||||
event.type === 'SWAP_INITIATED' ? event.targetProfileId : undefined,
|
||||
swapPhase: () => 'capturing' as const,
|
||||
error: () => undefined,
|
||||
}),
|
||||
setCapturedSession: assign({
|
||||
claudeSessionId: ({ event }) =>
|
||||
event.type === 'SWAP_SESSION_CAPTURED' ? event.claudeSessionId : undefined,
|
||||
}),
|
||||
setSwapPhaseMigrating: assign({ swapPhase: () => 'migrating' as const }),
|
||||
setSwapPhaseRecreating: assign({ swapPhase: () => 'recreating' as const }),
|
||||
setSwapPhaseResuming: assign({ swapPhase: () => 'resuming' as const }),
|
||||
applySwapComplete: assign({
|
||||
claudeSessionId: ({ event }) =>
|
||||
event.type === 'SWAP_RESUME_COMPLETE' ? event.claudeSessionId : undefined,
|
||||
profileId: ({ event }) =>
|
||||
event.type === 'SWAP_RESUME_COMPLETE' ? event.profileId : undefined,
|
||||
swapTargetProfileId: () => undefined,
|
||||
swapPhase: () => undefined,
|
||||
isBusy: () => false,
|
||||
error: () => undefined,
|
||||
}),
|
||||
clearSwapState: assign({
|
||||
swapTargetProfileId: () => undefined,
|
||||
swapPhase: () => undefined,
|
||||
}),
|
||||
resetContext: assign({
|
||||
claudeSessionId: () => undefined,
|
||||
profileId: () => undefined,
|
||||
swapTargetProfileId: () => undefined,
|
||||
swapPhase: () => undefined,
|
||||
isBusy: () => false,
|
||||
error: () => undefined,
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -255,6 +255,10 @@ export interface TerminalProfileChangedEvent {
|
||||
sessionId?: string;
|
||||
/** Whether the session was successfully migrated to new profile */
|
||||
sessionMigrated?: boolean;
|
||||
/** Whether the terminal was in Claude mode (had an active Claude session) */
|
||||
isClaudeMode?: boolean;
|
||||
/** Whether Claude was invoked with --dangerously-skip-permissions (YOLO mode) */
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ export interface ElectronAPI {
|
||||
getTerminalSessions: (projectPath: string) => Promise<IPCResult<TerminalSession[]>>;
|
||||
restoreTerminalSession: (session: TerminalSession, cols?: number, rows?: number) => Promise<IPCResult<TerminalRestoreResult>>;
|
||||
clearTerminalSessions: (projectPath: string) => Promise<IPCResult>;
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string) => void;
|
||||
resumeClaudeInTerminal: (id: string, sessionId?: string, options?: { migratedSession?: boolean }) => void;
|
||||
activateDeferredClaudeResume: (id: string) => void;
|
||||
getTerminalSessionDates: (projectPath?: string) => Promise<IPCResult<SessionDateInfo[]>>;
|
||||
getTerminalSessionsForDate: (date: string, projectPath: string) => Promise<IPCResult<TerminalSession[]>>;
|
||||
|
||||
Reference in New Issue
Block a user