feat: add Terminal Dashboard components and state management

- Implement TerminalTabBar for session tab management with status indicators and alert badges.
- Create TerminalWorkbench to combine TerminalTabBar and TerminalInstance for terminal session display.
- Add localization support for terminal dashboard in English and Chinese.
- Develop TerminalDashboardPage for the main layout of the terminal dashboard with a three-column structure.
- Introduce Zustand stores for session management and issue/queue integration, handling session groups, terminal metadata, and alert management.
- Create a monitor web worker for off-main-thread output analysis, detecting errors and stalls in terminal sessions.
- Define TypeScript types for terminal dashboard state management and integration.
This commit is contained in:
catlog22
2026-02-14 20:54:05 +08:00
parent 4d22ae4b2f
commit e4b898f401
37 changed files with 2810 additions and 5438 deletions

View File

@@ -123,6 +123,25 @@ export {
selectPlanStepByExecutionId,
} from './orchestratorStore';
// Session Manager Store
export {
useSessionManagerStore,
selectGroups,
selectLayout,
selectSessionManagerActiveTerminalId,
selectTerminalMetas,
selectTerminalMeta,
} from './sessionManagerStore';
// Issue Queue Integration Store
export {
useIssueQueueIntegrationStore,
selectSelectedIssueId,
selectAssociationChain,
selectQueueByIssue,
selectIssueById,
} from './issueQueueIntegrationStore';
// Terminal Panel Store Types
export type {
PanelView,
@@ -241,3 +260,25 @@ export type {
} from '../types/flow';
export { NODE_TYPE_CONFIGS } from '../types/flow';
// Session Manager Store Types
export type {
SessionGridLayout,
SessionLayout,
TerminalStatus,
TerminalMeta,
SessionGroup,
SessionManagerState,
SessionManagerActions,
SessionManagerStore,
AlertSeverity,
MonitorAlert,
} from '../types/terminal-dashboard';
// Issue Queue Integration Store Types
export type {
AssociationChain,
IssueQueueIntegrationState,
IssueQueueIntegrationActions,
IssueQueueIntegrationStore,
} from '../types/terminal-dashboard';

View File

@@ -0,0 +1,232 @@
// ========================================
// Issue Queue Integration Store
// ========================================
// Zustand store bridging issue/queue data with execution tracking.
// Manages association chain state for highlight linkage across
// Issue <-> QueueItem <-> Terminal Session.
//
// Design principles:
// - Does NOT duplicate issues[]/queue[] arrays (use React Query hooks for data)
// - Bridges queueExecutionStore via getState() for execution status
// - Manages UI-specific integration state (selection, association chain)
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
AssociationChain,
IssueQueueIntegrationState,
IssueQueueIntegrationStore,
} from '../types/terminal-dashboard';
import { useQueueExecutionStore } from './queueExecutionStore';
// ========== Initial State ==========
const initialState: IssueQueueIntegrationState = {
selectedIssueId: null,
associationChain: null,
};
// ========== Store ==========
export const useIssueQueueIntegrationStore = create<IssueQueueIntegrationStore>()(
devtools(
(set) => ({
...initialState,
// ========== Issue Selection ==========
setSelectedIssue: (issueId: string | null) => {
if (issueId === null) {
set(
{ selectedIssueId: null, associationChain: null },
false,
'setSelectedIssue/clear'
);
return;
}
// Resolve association chain from issue ID
const chain = resolveChainFromIssue(issueId);
set(
{ selectedIssueId: issueId, associationChain: chain },
false,
'setSelectedIssue'
);
},
// ========== Association Chain ==========
buildAssociationChain: (
entityId: string,
entityType: 'issue' | 'queue' | 'session'
) => {
let chain: AssociationChain;
switch (entityType) {
case 'issue':
chain = resolveChainFromIssue(entityId);
break;
case 'queue':
chain = resolveChainFromQueueItem(entityId);
break;
case 'session':
chain = resolveChainFromSession(entityId);
break;
default:
chain = { issueId: null, queueItemId: null, sessionId: null };
}
set(
{
associationChain: chain,
selectedIssueId: chain.issueId,
},
false,
'buildAssociationChain'
);
},
// ========== Queue Status Bridge ==========
_updateQueueItemStatus: (
queueItemId: string,
status: string,
sessionId?: string
) => {
// Bridge to queueExecutionStore for execution tracking
const execStore = useQueueExecutionStore.getState();
const executions = Object.values(execStore.executions);
const matchedExec = executions.find(
(e) => e.queueItemId === queueItemId
);
if (matchedExec) {
// Update status in the execution store
const validStatuses = ['pending', 'running', 'completed', 'failed'] as const;
type ValidStatus = (typeof validStatuses)[number];
if (validStatuses.includes(status as ValidStatus)) {
execStore.updateStatus(matchedExec.id, status as ValidStatus);
}
}
// If a sessionId is provided, update the association chain
if (sessionId) {
set(
(state) => {
if (
state.associationChain &&
state.associationChain.queueItemId === queueItemId
) {
return {
associationChain: {
...state.associationChain,
sessionId,
},
};
}
return state;
},
false,
'_updateQueueItemStatus'
);
}
},
}),
{ name: 'IssueQueueIntegrationStore' }
)
);
// ========== Chain Resolution Helpers ==========
/**
* Resolve association chain starting from an issue ID.
* Looks up queueExecutionStore to find linked queue items and sessions.
*/
function resolveChainFromIssue(issueId: string): AssociationChain {
const executions = Object.values(
useQueueExecutionStore.getState().executions
);
// Find the first execution matching this issue
const matched = executions.find((e) => e.issueId === issueId);
if (matched) {
return {
issueId,
queueItemId: matched.queueItemId,
sessionId: matched.sessionKey ?? null,
};
}
return { issueId, queueItemId: null, sessionId: null };
}
/**
* Resolve association chain starting from a queue item ID.
* Looks up queueExecutionStore to find linked issue and session.
*/
function resolveChainFromQueueItem(queueItemId: string): AssociationChain {
const executions = Object.values(
useQueueExecutionStore.getState().executions
);
const matched = executions.find((e) => e.queueItemId === queueItemId);
if (matched) {
return {
issueId: matched.issueId,
queueItemId,
sessionId: matched.sessionKey ?? null,
};
}
return { issueId: null, queueItemId, sessionId: null };
}
/**
* Resolve association chain starting from a session key.
* Looks up queueExecutionStore to find linked issue and queue item.
*/
function resolveChainFromSession(sessionId: string): AssociationChain {
const executions = Object.values(
useQueueExecutionStore.getState().executions
);
const matched = executions.find((e) => e.sessionKey === sessionId);
if (matched) {
return {
issueId: matched.issueId,
queueItemId: matched.queueItemId,
sessionId,
};
}
return { issueId: null, queueItemId: null, sessionId };
}
// ========== Selectors ==========
/** Select currently selected issue ID */
export const selectSelectedIssueId = (state: IssueQueueIntegrationStore) =>
state.selectedIssueId;
/** Select current association chain */
export const selectAssociationChain = (state: IssueQueueIntegrationStore) =>
state.associationChain;
/**
* Select queue executions for a specific issue.
* WARNING: Returns new array each call - use with useMemo in components.
*/
export const selectQueueByIssue =
(issueId: string) =>
(): import('./queueExecutionStore').QueueExecution[] => {
const executions = Object.values(
useQueueExecutionStore.getState().executions
);
return executions.filter((e) => e.issueId === issueId);
};
/**
* Select an issue's execution by issue ID (first matched).
* Returns the execution record or undefined.
*/
export const selectIssueById =
(issueId: string) =>
(): import('./queueExecutionStore').QueueExecution | undefined => {
const executions = Object.values(
useQueueExecutionStore.getState().executions
);
return executions.find((e) => e.issueId === issueId);
};

View File

@@ -0,0 +1,205 @@
// ========================================
// Session Manager Store
// ========================================
// Zustand store for terminal dashboard session management.
// Manages session groups, layout, active terminal, terminal metadata,
// and monitor Web Worker lifecycle.
// Consumes cliSessionStore data via getState() pattern (no data duplication).
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
MonitorAlert,
SessionGroup,
SessionLayout,
SessionManagerState,
SessionManagerStore,
TerminalMeta,
TerminalStatus,
} from '../types/terminal-dashboard';
// ========== Initial State ==========
const initialState: SessionManagerState = {
groups: [],
layout: { grid: '1x1', splits: [1] },
activeTerminalId: null,
terminalMetas: {},
};
// ========== Worker Ref (non-reactive, outside Zustand) ==========
/** Module-level worker reference. Worker objects are not serializable. */
let _workerRef: Worker | null = null;
// ========== Worker Message Handler ==========
function _handleWorkerMessage(event: MessageEvent<MonitorAlert>): void {
const msg = event.data;
if (msg.type !== 'alert') return;
const { sessionId, severity, message } = msg;
// Map severity to terminal status
const statusMap: Record<string, TerminalStatus> = {
critical: 'error',
warning: 'idle',
};
const store = useSessionManagerStore.getState();
const existing = store.terminalMetas[sessionId];
const currentAlertCount = existing?.alertCount ?? 0;
store.updateTerminalMeta(sessionId, {
status: statusMap[severity] ?? 'idle',
alertCount: currentAlertCount + 1,
});
// Log for debugging (non-intrusive)
if (import.meta.env.DEV) {
console.debug(`[MonitorWorker] ${severity}: ${message} (session=${sessionId})`);
}
}
// ========== Store ==========
export const useSessionManagerStore = create<SessionManagerStore>()(
devtools(
(set) => ({
...initialState,
// ========== Group Management ==========
createGroup: (name: string) => {
const id = `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const newGroup: SessionGroup = { id, name, sessionIds: [] };
set(
(state) => ({ groups: [...state.groups, newGroup] }),
false,
'createGroup'
);
},
removeGroup: (groupId: string) => {
set(
(state) => ({ groups: state.groups.filter((g) => g.id !== groupId) }),
false,
'removeGroup'
);
},
moveSessionToGroup: (sessionId: string, groupId: string) => {
set(
(state) => {
const nextGroups = state.groups.map((group) => {
// Remove session from its current group
const filtered = group.sessionIds.filter((id) => id !== sessionId);
// Add to target group
if (group.id === groupId) {
return { ...group, sessionIds: [...filtered, sessionId] };
}
return { ...group, sessionIds: filtered };
});
return { groups: nextGroups };
},
false,
'moveSessionToGroup'
);
},
// ========== Terminal Selection ==========
setActiveTerminal: (sessionId: string | null) => {
set({ activeTerminalId: sessionId }, false, 'setActiveTerminal');
},
// ========== Terminal Metadata ==========
updateTerminalMeta: (sessionId: string, meta: Partial<TerminalMeta>) => {
set(
(state) => {
const existing = state.terminalMetas[sessionId] ?? {
title: sessionId,
status: 'idle' as const,
alertCount: 0,
};
return {
terminalMetas: {
...state.terminalMetas,
[sessionId]: { ...existing, ...meta },
},
};
},
false,
'updateTerminalMeta'
);
},
// ========== Layout Management ==========
setGroupLayout: (layout: SessionLayout) => {
set({ layout }, false, 'setGroupLayout');
},
// ========== Monitor Worker Lifecycle ==========
spawnMonitor: () => {
// Idempotent: only create if not already running
if (_workerRef) return;
try {
_workerRef = new Worker(
new URL('../workers/monitor.worker.ts', import.meta.url),
{ type: 'module' }
);
_workerRef.onmessage = _handleWorkerMessage;
_workerRef.onerror = (err) => {
if (import.meta.env.DEV) {
console.error('[MonitorWorker] error:', err);
}
};
} catch {
// Worker creation can fail in environments without worker support
_workerRef = null;
}
},
terminateMonitor: () => {
if (!_workerRef) return;
_workerRef.terminate();
_workerRef = null;
},
feedMonitor: (sessionId: string, text: string) => {
// Lazily spawn worker on first feed call
if (!_workerRef) {
useSessionManagerStore.getState().spawnMonitor();
}
if (_workerRef) {
_workerRef.postMessage({ type: 'output', sessionId, text });
}
},
}),
{ name: 'SessionManagerStore' }
)
);
// ========== Selectors ==========
/** Select all session groups */
export const selectGroups = (state: SessionManagerStore) => state.groups;
/** Select current terminal layout */
export const selectLayout = (state: SessionManagerStore) => state.layout;
/** Select active terminal session key */
export const selectSessionManagerActiveTerminalId = (state: SessionManagerStore) =>
state.activeTerminalId;
/** Select all terminal metadata records */
export const selectTerminalMetas = (state: SessionManagerStore) => state.terminalMetas;
/** Select terminal metadata for a specific session */
export const selectTerminalMeta =
(sessionId: string) =>
(state: SessionManagerStore): TerminalMeta | undefined =>
state.terminalMetas[sessionId];