feat: Add CLI session pause and resume functionality with UI integration

This commit is contained in:
catlog22
2026-02-15 10:30:11 +08:00
parent 8e8fdcfcac
commit 731f1ea775
13 changed files with 465 additions and 5 deletions

View File

@@ -15,6 +15,7 @@ export interface CliSessionMeta {
resumeKey?: string;
createdAt: string;
updatedAt: string;
isPaused: boolean;
}
export interface CliSessionOutputChunk {
@@ -30,6 +31,7 @@ interface CliSessionState {
setSessions: (sessions: CliSessionMeta[]) => void;
upsertSession: (session: CliSessionMeta) => void;
removeSession: (sessionKey: string) => void;
updateSessionPausedState: (sessionKey: string, isPaused: boolean) => void;
setBuffer: (sessionKey: string, buffer: string) => void;
appendOutput: (sessionKey: string, data: string, timestamp?: number) => void;
@@ -87,6 +89,18 @@ export const useCliSessionStore = create<CliSessionState>()(
return { sessions: nextSessions, outputChunks: nextChunks, outputBytes: nextBytes };
}),
updateSessionPausedState: (sessionKey, isPaused) =>
set((state) => {
const session = state.sessions[sessionKey];
if (!session) return state;
return {
sessions: {
...state.sessions,
[sessionKey]: { ...session, isPaused, updatedAt: new Date().toISOString() },
},
};
}),
setBuffer: (sessionKey, buffer) =>
set((state) => ({
outputChunks: {

View File

@@ -17,6 +17,9 @@ import type {
TerminalMeta,
TerminalStatus,
} from '../types/terminal-dashboard';
import { pauseCliSession, resumeCliSession, closeCliSession, createCliSession } from '../lib/api';
import { useCliSessionStore } from './cliSessionStore';
import { useWorkflowStore, selectProjectPath } from './workflowStore';
// ========== Initial State ==========
@@ -178,6 +181,150 @@ export const useSessionManagerStore = create<SessionManagerStore>()(
_workerRef.postMessage({ type: 'output', sessionId, text });
}
},
// ========== Session Lifecycle Actions ==========
pauseSession: async (terminalId: string) => {
const projectPath = selectProjectPath(useWorkflowStore.getState());
try {
await pauseCliSession(terminalId, projectPath ?? undefined);
set(
(state) => {
const existing = state.terminalMetas[terminalId];
if (!existing) return state;
return {
terminalMetas: {
...state.terminalMetas,
[terminalId]: { ...existing, status: 'paused' as TerminalStatus },
},
};
},
false,
'pauseSession'
);
} catch (error) {
if (import.meta.env.DEV) {
console.error('[SessionManager] pauseSession error:', error);
}
throw error;
}
},
resumeSession: async (terminalId: string) => {
const projectPath = selectProjectPath(useWorkflowStore.getState());
// First update to 'resuming' status
set(
(state) => {
const existing = state.terminalMetas[terminalId];
if (!existing) return state;
return {
terminalMetas: {
...state.terminalMetas,
[terminalId]: { ...existing, status: 'resuming' as TerminalStatus },
},
};
},
false,
'resumeSession/pending'
);
try {
await resumeCliSession(terminalId, projectPath ?? undefined);
// On success, update to 'active' status
set(
(state) => {
const existing = state.terminalMetas[terminalId];
if (!existing) return state;
return {
terminalMetas: {
...state.terminalMetas,
[terminalId]: { ...existing, status: 'active' as TerminalStatus },
},
};
},
false,
'resumeSession/fulfilled'
);
} catch (error) {
// On error, revert to 'paused' status
set(
(state) => {
const existing = state.terminalMetas[terminalId];
if (!existing) return state;
return {
terminalMetas: {
...state.terminalMetas,
[terminalId]: { ...existing, status: 'paused' as TerminalStatus },
},
};
},
false,
'resumeSession/rejected'
);
if (import.meta.env.DEV) {
console.error('[SessionManager] resumeSession error:', error);
}
throw error;
}
},
restartSession: async (terminalId: string) => {
const projectPath = selectProjectPath(useWorkflowStore.getState());
const cliStore = useCliSessionStore.getState();
const session = cliStore.sessions[terminalId];
if (!session) {
throw new Error(`Session not found: ${terminalId}`);
}
// Store session config for recreation
const sessionConfig = {
workingDir: session.workingDir,
tool: session.tool,
model: session.model,
resumeKey: session.resumeKey,
shellKind: session.shellKind,
};
try {
// Close existing session
await closeCliSession(terminalId, projectPath ?? undefined);
// Create new session with same config
const result = await createCliSession(
{
workingDir: sessionConfig.workingDir,
preferredShell: sessionConfig.shellKind === 'powershell' ? 'pwsh' : 'bash',
tool: sessionConfig.tool,
model: sessionConfig.model,
resumeKey: sessionConfig.resumeKey,
},
projectPath ?? undefined
);
// Update terminal meta to active status
set(
(state) => {
const existing = state.terminalMetas[terminalId];
if (!existing) return state;
return {
terminalMetas: {
...state.terminalMetas,
[terminalId]: { ...existing, status: 'active' as TerminalStatus, alertCount: 0 },
},
};
},
false,
'restartSession'
);
return result;
} catch (error) {
if (import.meta.env.DEV) {
console.error('[SessionManager] restartSession error:', error);
}
throw error;
}
},
}),
{ name: 'SessionManagerStore' }
)

View File

@@ -13,6 +13,8 @@ import {
removePaneFromLayout,
getAllPaneIds,
} from '@/lib/layout-utils';
import type { CreateCliSessionInput, CliSession } from '@/lib/api';
import { createCliSession } from '@/lib/api';
// ========== Types ==========
@@ -37,6 +39,12 @@ export interface TerminalGridActions {
assignSession: (paneId: PaneId, sessionId: string | null) => void;
setFocused: (paneId: PaneId) => void;
resetLayout: (preset: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => void;
/** Create a new CLI session and assign it to a new pane (auto-split from specified pane) */
createSessionAndAssign: (
paneId: PaneId,
config: CreateCliSessionInput,
projectPath: string | null
) => Promise<{ paneId: PaneId; session: CliSession } | null>;
}
export type TerminalGridStore = TerminalGridState & TerminalGridActions;
@@ -231,6 +239,60 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
'terminalGrid/resetLayout'
);
},
createSessionAndAssign: async (paneId, config, projectPath) => {
try {
// 1. Create the CLI session via API
const result = await createCliSession(config, projectPath ?? undefined);
const session = result.session;
// 2. Get current state
const state = get();
// 3. Check if the current pane is empty (no session assigned)
const currentPane = state.panes[paneId];
const isCurrentPaneEmpty = !currentPane?.sessionId;
if (isCurrentPaneEmpty) {
// Assign session to current empty pane
set(
{
panes: {
...state.panes,
[paneId]: { ...currentPane, sessionId: session.sessionKey },
},
focusedPaneId: paneId,
},
false,
'terminalGrid/createSessionAndAssign'
);
return { paneId, session };
}
// 4. Current pane has session, auto-split and assign to new pane
const newPaneId = generatePaneId(state.nextPaneIdCounter);
const newLayout = addPaneToLayout(state.layout, newPaneId, paneId, 'horizontal');
set(
{
layout: newLayout,
panes: {
...state.panes,
[newPaneId]: { id: newPaneId, sessionId: session.sessionKey },
},
focusedPaneId: newPaneId,
nextPaneIdCounter: state.nextPaneIdCounter + 1,
},
false,
'terminalGrid/createSessionAndAssign'
);
return { paneId: newPaneId, session };
} catch (error) {
console.error('Failed to create CLI session:', error);
return null;
}
},
}),
{ name: 'TerminalGridStore' }
),