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;