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

@@ -5,7 +5,7 @@
// Provides toggle buttons for floating panels (Issues/Queue/Inspector)
// and layout preset controls. Sessions sidebar is always visible.
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import {
AlertCircle,
@@ -15,15 +15,28 @@ import {
Columns2,
Rows2,
Square,
Terminal,
ChevronDown,
Zap,
Settings,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import {
useIssueQueueIntegrationStore,
selectAssociationChain,
} from '@/stores/issueQueueIntegrationStore';
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
import { useTerminalGridStore } from '@/stores/terminalGridStore';
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// ========== Types ==========
@@ -76,8 +89,76 @@ export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolba
[resetLayout]
);
// Launch CLI handlers
const projectPath = useWorkflowStore(selectProjectPath);
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
const [isCreating, setIsCreating] = useState(false);
const handleQuickCreate = useCallback(async () => {
if (!focusedPaneId || !projectPath) return;
setIsCreating(true);
try {
await createSessionAndAssign(focusedPaneId, {
workingDir: projectPath,
preferredShell: 'bash',
}, projectPath);
} finally {
setIsCreating(false);
}
}, [focusedPaneId, projectPath, createSessionAndAssign]);
const handleConfigure = useCallback(() => {
// TODO: Open configuration modal (future implementation)
console.log('Configure CLI session - modal to be implemented');
}, []);
return (
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
{/* Launch CLI dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted',
isCreating && 'opacity-50 cursor-wait'
)}
disabled={isCreating || !projectPath}
>
{isCreating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Terminal className="w-3.5 h-3.5" />
)}
<span>{formatMessage({ id: 'terminalDashboard.toolbar.launchCli' })}</span>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
<DropdownMenuItem
onClick={handleQuickCreate}
disabled={isCreating || !projectPath || !focusedPaneId}
className="gap-2"
>
<Zap className="w-4 h-4" />
<span>{formatMessage({ id: 'terminalDashboard.toolbar.quickCreate' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleConfigure}
disabled={isCreating}
className="gap-2"
>
<Settings className="w-4 h-4" />
<span>{formatMessage({ id: 'terminalDashboard.toolbar.configure' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Panel toggle buttons */}
<ToolbarButton
icon={AlertCircle}

View File

@@ -42,6 +42,8 @@ const statusDotStyles: Record<TerminalStatus, string> = {
active: 'bg-green-500',
idle: 'bg-gray-400',
error: 'bg-red-500',
paused: 'bg-yellow-500',
resuming: 'bg-blue-400 animate-pulse',
};
// ========== Props ==========

View File

@@ -63,6 +63,7 @@ function getStoreState() {
upsertCliSession: cliSessions.upsertSession,
removeCliSession: cliSessions.removeSession,
appendCliSessionOutput: cliSessions.appendOutput,
updateCliSessionPausedState: cliSessions.updateSessionPausedState,
};
}
@@ -195,6 +196,22 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
break;
}
case 'CLI_SESSION_PAUSED': {
const { sessionKey } = data.payload ?? {};
if (typeof sessionKey === 'string') {
stores.updateCliSessionPausedState(sessionKey, true);
}
break;
}
case 'CLI_SESSION_RESUMED': {
const { sessionKey } = data.payload ?? {};
if (typeof sessionKey === 'string') {
stores.updateCliSessionPausedState(sessionKey, false);
}
break;
}
case 'CLI_OUTPUT': {
const { executionId, chunkType, data: outputData, unit } = data.payload;

View File

@@ -6150,6 +6150,7 @@ export interface CliSession {
resumeKey?: string;
createdAt: string;
updatedAt: string;
isPaused: boolean;
}
export interface CreateCliSessionInput {
@@ -6242,6 +6243,20 @@ export async function closeCliSession(sessionKey: string, projectPath?: string):
});
}
export async function pauseCliSession(sessionKey: string, projectPath?: string): Promise<{ success: boolean }> {
return fetchApi<{ success: boolean }>(withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/pause`, projectPath), {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function resumeCliSession(sessionKey: string, projectPath?: string): Promise<{ success: boolean }> {
return fetchApi<{ success: boolean }>(withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/resume`, projectPath), {
method: 'POST',
body: JSON.stringify({}),
});
}
export async function createCliSessionShareToken(
sessionKey: string,
input: { mode?: 'read' | 'write'; ttlMs?: number },

View File

@@ -76,7 +76,10 @@
"layoutSingle": "Single",
"layoutSplitH": "Split Horizontal",
"layoutSplitV": "Split Vertical",
"layoutGrid": "Grid 2×2"
"layoutGrid": "Grid 2x2",
"launchCli": "Launch CLI",
"quickCreate": "Quick Create",
"configure": "Configure..."
},
"pane": {
"selectSession": "Select a session",

View File

@@ -76,7 +76,10 @@
"layoutSingle": "单窗格",
"layoutSplitH": "左右分割",
"layoutSplitV": "上下分割",
"layoutGrid": "2×2 网格"
"layoutGrid": "2x2 网格",
"launchCli": "启动 CLI",
"quickCreate": "快速创建",
"configure": "配置..."
},
"pane": {
"selectSession": "选择会话",

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' }
),

View File

@@ -18,7 +18,7 @@ export interface SessionLayout {
}
/** Terminal status indicator */
export type TerminalStatus = 'active' | 'idle' | 'error';
export type TerminalStatus = 'active' | 'idle' | 'error' | 'paused' | 'resuming';
/** Metadata for a terminal instance in the dashboard */
export interface TerminalMeta {
@@ -83,6 +83,12 @@ export interface SessionManagerActions {
terminateMonitor: () => void;
/** Forward a terminal output chunk to the monitor worker */
feedMonitor: (sessionId: string, text: string) => void;
/** Pause a terminal session (SIGSTOP) */
pauseSession: (terminalId: string) => Promise<void>;
/** Resume a paused terminal session (SIGCONT) */
resumeSession: (terminalId: string) => Promise<void>;
/** Restart a terminal session (close and recreate with same config) */
restartSession: (terminalId: string) => Promise<void>;
}
export type SessionManagerStore = SessionManagerState & SessionManagerActions;

View File

@@ -444,5 +444,59 @@ export async function handleCliSessionsRoutes(ctx: RouteContext): Promise<boolea
return true;
}
// POST /api/cli-sessions/:sessionKey/pause
const pauseMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/pause$/);
if (pauseMatch && req.method === 'POST') {
const sessionKey = decodeURIComponent(pauseMatch[1]);
try {
manager.pauseSession(sessionKey);
appendCliSessionAudit({
type: 'session_paused',
timestamp: new Date().toISOString(),
projectRoot,
sessionKey,
...clientInfo(req),
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (err) {
const message = (err as Error).message;
if (message.includes('not found')) {
res.writeHead(404, { 'Content-Type': 'application/json' });
} else {
res.writeHead(400, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: message }));
}
return true;
}
// POST /api/cli-sessions/:sessionKey/resume
const resumeMatch = pathname.match(/^\/api\/cli-sessions\/([^/]+)\/resume$/);
if (resumeMatch && req.method === 'POST') {
const sessionKey = decodeURIComponent(resumeMatch[1]);
try {
manager.resumeSession(sessionKey);
appendCliSessionAudit({
type: 'session_resumed',
timestamp: new Date().toISOString(),
projectRoot,
sessionKey,
...clientInfo(req),
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (err) {
const message = (err as Error).message;
if (message.includes('not found')) {
res.writeHead(404, { 'Content-Type': 'application/json' });
} else {
res.writeHead(400, { 'Content-Type': 'application/json' });
}
res.end(JSON.stringify({ error: message }));
}
return true;
}
return false;
}

View File

@@ -4,6 +4,8 @@ import path from 'path';
export type CliSessionAuditEventType =
| 'session_created'
| 'session_closed'
| 'session_paused'
| 'session_resumed'
| 'session_send'
| 'session_execute'
| 'session_resize'

View File

@@ -23,6 +23,7 @@ export interface CliSession {
resumeKey?: string;
createdAt: string;
updatedAt: string;
isPaused: boolean;
}
export interface CreateCliSessionOptions {
@@ -57,6 +58,7 @@ interface CliSessionInternal extends CliSession {
buffer: string[];
bufferBytes: number;
lastActivityAt: number;
isPaused: boolean;
}
function nowIso(): string {
@@ -227,6 +229,7 @@ export class CliSessionManager {
buffer: [],
bufferBytes: 0,
lastActivityAt: Date.now(),
isPaused: false,
};
pty.onData((data) => {
@@ -318,6 +321,57 @@ export class CliSessionManager {
}
}
pauseSession(sessionKey: string): void {
const session = this.sessions.get(sessionKey);
if (!session) {
throw new Error(`Session not found: ${sessionKey}`);
}
if (session.isPaused) {
throw new Error(`Session already paused: ${sessionKey}`);
}
const pid = session.pty.pid;
if (pid === undefined) {
throw new Error(`Session PTY has no PID: ${sessionKey}`);
}
try {
process.kill(pid, 'SIGSTOP');
session.isPaused = true;
session.updatedAt = nowIso();
broadcastToClients({
type: 'CLI_SESSION_PAUSED',
payload: { sessionKey, timestamp: nowIso() }
});
} catch (err) {
throw new Error(`Failed to pause session ${sessionKey}: ${(err as Error).message}`);
}
}
resumeSession(sessionKey: string): void {
const session = this.sessions.get(sessionKey);
if (!session) {
throw new Error(`Session not found: ${sessionKey}`);
}
if (!session.isPaused) {
throw new Error(`Session is not paused: ${sessionKey}`);
}
const pid = session.pty.pid;
if (pid === undefined) {
throw new Error(`Session PTY has no PID: ${sessionKey}`);
}
try {
process.kill(pid, 'SIGCONT');
session.isPaused = false;
session.updatedAt = nowIso();
session.lastActivityAt = Date.now();
broadcastToClients({
type: 'CLI_SESSION_RESUMED',
payload: { sessionKey, timestamp: nowIso() }
});
} catch (err) {
throw new Error(`Failed to resume session ${sessionKey}: ${(err as Error).message}`);
}
}
execute(sessionKey: string, options: ExecuteInCliSessionOptions): { executionId: string; command: string } {
const session = this.sessions.get(sessionKey);
if (!session) {