mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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';
|
||||
|
||||
232
ccw/frontend/src/stores/issueQueueIntegrationStore.ts
Normal file
232
ccw/frontend/src/stores/issueQueueIntegrationStore.ts
Normal 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);
|
||||
};
|
||||
205
ccw/frontend/src/stores/sessionManagerStore.ts
Normal file
205
ccw/frontend/src/stores/sessionManagerStore.ts
Normal 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];
|
||||
Reference in New Issue
Block a user