auto-claude: subtask-3-2 - Integrate XState terminal machine into terminal-store

Add module-level terminalActors Map and helper functions (getOrCreateTerminalActor,
sendTerminalMachineEvent, deriveTerminalStateFromMachine) for XState actor management.
Store actions (setClaudeMode, setClaudeSessionId, setClaudeBusy, setPendingClaudeResume)
now forward corresponding events to the XState machine. Actors are cleaned up on
terminal removal and clearAllTerminals.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
AndyMik90
2026-02-17 15:32:35 +01:00
co-authored by Claude Opus 4.6
parent 6c991774ad
commit 1302c61b71
@@ -1,10 +1,66 @@
import { create } from 'zustand';
import { createActor } from 'xstate';
import type { ActorRefFrom, SnapshotFrom } 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})`);
}
/**
* Derive terminal boolean fields from an XState machine snapshot.
* Used to keep legacy boolean fields in sync with the machine state.
*/
export function deriveTerminalStateFromMachine(snapshot: SnapshotFrom<typeof terminalMachine>): {
isClaudeMode: boolean;
isClaudeBusy: boolean;
pendingClaudeResume: boolean;
} {
const state = String(snapshot.value);
const isClaudeMode = state === 'claude_starting' || state === 'claude_active' || state === 'swapping';
const isClaudeBusy = snapshot.context.isBusy;
const pendingClaudeResume = state === 'pending_resume';
return { isClaudeMode, isClaudeBusy, pendingClaudeResume };
}
/**
* Module-level Map to store terminal ID -> xterm write callback mappings.
*
@@ -301,9 +357,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);
@@ -339,6 +401,13 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
},
setClaudeMode: (id: string, isClaudeMode: boolean) => {
// Send corresponding event to XState machine
if (isClaudeMode) {
sendTerminalMachineEvent(id, { type: 'CLAUDE_ACTIVE' });
} else {
sendTerminalMachineEvent(id, { type: 'CLAUDE_EXITED' });
}
set((state) => ({
terminals: state.terminals.map((t) =>
t.id === id
@@ -356,6 +425,9 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
},
setClaudeSessionId: (id: string, sessionId: string) => {
// 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 +452,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,6 +463,20 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
},
setPendingClaudeResume: (id: string, pending: boolean) => {
// Send RESUME_REQUESTED or RESUME_COMPLETE to XState machine
if (pending) {
const terminal = get().terminals.find(t => t.id === id);
if (terminal?.claudeSessionId) {
sendTerminalMachineEvent(id, { type: 'RESUME_REQUESTED', claudeSessionId: terminal.claudeSessionId });
}
} else {
// Resume cleared - either completed or cancelled
const actor = terminalActors.get(id);
if (actor && String(actor.getSnapshot().value) === 'pending_resume') {
sendTerminalMachineEvent(id, { type: 'RESUME_COMPLETE' });
}
}
set((state) => ({
terminals: state.terminals.map((t) =>
t.id === id ? { ...t, pendingClaudeResume: pending } : t
@@ -404,6 +493,11 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
},
clearAllTerminals: () => {
// Clean up all XState actors
for (const [_id, actor] of terminalActors) {
actor.stop();
}
terminalActors.clear();
set({ terminals: [], activeTerminalId: null, hasRestoredSessions: false });
},