feat: add tests and implementation for issue discovery and queue pages

- Implemented `DiscoveryPage` with session management and findings display.
- Added tests for `DiscoveryPage` to ensure proper rendering and functionality.
- Created `QueuePage` for managing issue execution queues with stats and actions.
- Added tests for `QueuePage` to verify UI elements and translations.
- Introduced `useIssues` hooks for fetching and managing issue data.
- Added loading skeletons and error handling for better user experience.
- Created `vite-env.d.ts` for TypeScript support in Vite environment.
This commit is contained in:
catlog22
2026-01-31 21:20:10 +08:00
parent 6d225948d1
commit 1bd082a725
79 changed files with 5870 additions and 449 deletions

View File

@@ -35,10 +35,45 @@ export interface CliExecutionState {
recovered?: boolean;
}
/**
* Log line within a block (minimal interface compatible with LogBlock component)
* This matches the LogLine type in components/shared/LogBlock/types.ts
*/
export interface LogLine {
type: 'stdout' | 'stderr' | 'thought' | 'system' | 'metadata' | 'tool_call';
content: string;
timestamp: number;
}
/**
* Log block data (minimal interface compatible with LogBlock component)
* This matches the LogBlockData type in components/shared/LogBlock/types.ts
* Defined here to avoid circular dependencies
*/
export interface LogBlockData {
id: string;
title: string;
type: 'command' | 'tool' | 'output' | 'error' | 'warning' | 'info';
status: 'running' | 'completed' | 'error' | 'pending';
toolName?: string;
lineCount: number;
duration?: number;
lines: LogLine[];
timestamp: number;
}
/**
* Block cache state
*/
interface BlockCacheState {
blocks: Record<string, LogBlockData[]>; // executionId -> cached blocks
lastUpdate: Record<string, number>; // executionId -> timestamp of last cache update
}
/**
* CLI stream state interface
*/
interface CliStreamState {
interface CliStreamState extends BlockCacheState {
outputs: Record<string, CliOutputLine[]>;
executions: Record<string, CliExecutionState>;
currentExecutionId: string | null;
@@ -53,6 +88,10 @@ interface CliStreamState {
upsertExecution: (executionId: string, exec: Partial<CliExecutionState> & { tool?: string; mode?: string }) => void;
removeExecution: (executionId: string) => void;
setCurrentExecution: (executionId: string | null) => void;
// Block cache methods
getBlocks: (executionId: string) => LogBlockData[];
invalidateBlocks: (executionId: string) => void;
}
// ========== Constants ==========
@@ -63,6 +102,203 @@ interface CliStreamState {
*/
const MAX_OUTPUT_LINES = 5000;
// ========== Helper Functions ==========
/**
* Parse tool call metadata from content
* Expected format: "[Tool] toolName(args)"
*/
function parseToolCallMetadata(content: string): { toolName: string; args: string } | undefined {
const toolCallMatch = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
if (toolCallMatch) {
return {
toolName: toolCallMatch[1],
args: toolCallMatch[2] || '',
};
}
return undefined;
}
/**
* Generate block title based on type and content
*/
function generateBlockTitle(lineType: string, content: string): string {
switch (lineType) {
case 'tool_call':
const metadata = parseToolCallMetadata(content);
if (metadata) {
return metadata.args ? `${metadata.toolName}(${metadata.args})` : metadata.toolName;
}
return 'Tool Call';
case 'thought':
return 'Thought';
case 'system':
return 'System';
case 'stderr':
return 'Error Output';
case 'stdout':
return 'Output';
case 'metadata':
return 'Metadata';
default:
return 'Log';
}
}
/**
* Get block type for a line
*/
function getBlockType(lineType: string): LogBlockData['type'] {
switch (lineType) {
case 'tool_call':
return 'tool';
case 'thought':
return 'info';
case 'system':
return 'info';
case 'stderr':
return 'error';
case 'stdout':
case 'metadata':
default:
return 'output';
}
}
/**
* Check if a line type should start a new block
*/
function shouldStartNewBlock(lineType: string, currentBlockType: string | null): boolean {
// No current block exists
if (!currentBlockType) {
return true;
}
// These types always start new blocks
if (lineType === 'tool_call' || lineType === 'thought' || lineType === 'system') {
return true;
}
// stderr starts a new block if not already in stderr
if (lineType === 'stderr' && currentBlockType !== 'stderr') {
return true;
}
// tool_call block captures all following stdout/stderr until next tool_call
if (currentBlockType === 'tool_call' && (lineType === 'stdout' || lineType === 'stderr')) {
return false;
}
// stderr block captures all stderr until next different type
if (currentBlockType === 'stderr' && lineType === 'stderr') {
return false;
}
// stdout merges into current stdout block
if (currentBlockType === 'stdout' && lineType === 'stdout') {
return false;
}
// Different type - start new block
if (currentBlockType !== lineType) {
return true;
}
return false;
}
/**
* Group CLI output lines into log blocks
*
* Block grouping rules:
* 1. tool_call starts new block, includes following stdout/stderr until next tool_call
* 2. thought becomes independent block
* 3. system becomes independent block
* 4. stderr becomes highlighted block
* 5. Other stdout merges into normal blocks
*/
function groupLinesIntoBlocks(
lines: CliOutputLine[],
executionId: string,
executionStatus: 'running' | 'completed' | 'error'
): LogBlockData[] {
const blocks: LogBlockData[] = [];
let currentLines: LogLine[] = [];
let currentType: string | null = null;
let currentTitle = '';
let currentToolName: string | undefined;
let blockStartTime = 0;
let blockIndex = 0;
for (const line of lines) {
// Check if we need to start a new block
if (shouldStartNewBlock(line.type, currentType)) {
// Save current block if exists
if (currentLines.length > 0) {
const duration = blockStartTime > 0 ? line.timestamp - blockStartTime : undefined;
blocks.push({
id: `${executionId}-block-${blockIndex}`,
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
type: getBlockType(currentType || ''),
status: executionStatus === 'running' ? 'running' : 'completed',
toolName: currentToolName,
lineCount: currentLines.length,
duration,
lines: currentLines,
timestamp: blockStartTime,
});
blockIndex++;
}
// Start new block
currentType = line.type;
currentTitle = generateBlockTitle(line.type, line.content);
currentLines = [
{
type: line.type,
content: line.content,
timestamp: line.timestamp,
},
];
blockStartTime = line.timestamp;
// Extract tool name for tool_call blocks
if (line.type === 'tool_call') {
const metadata = parseToolCallMetadata(line.content);
currentToolName = metadata?.toolName;
} else {
currentToolName = undefined;
}
} else {
// Add line to current block
currentLines.push({
type: line.type,
content: line.content,
timestamp: line.timestamp,
});
}
}
// Finalize the last block
if (currentLines.length > 0) {
const lastLine = currentLines[currentLines.length - 1];
const duration = blockStartTime > 0 ? lastLine.timestamp - blockStartTime : undefined;
blocks.push({
id: `${executionId}-block-${blockIndex}`,
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
type: getBlockType(currentType || ''),
status: executionStatus === 'running' ? 'running' : 'completed',
toolName: currentToolName,
lineCount: currentLines.length,
duration,
lines: currentLines,
timestamp: blockStartTime,
});
}
return blocks;
}
// ========== Store ==========
/**
@@ -85,6 +321,10 @@ export const useCliStreamStore = create<CliStreamState>()(
executions: {},
currentExecutionId: null,
// Block cache state
blocks: {},
lastUpdate: {},
addOutput: (executionId: string, line: CliOutputLine) => {
set((state) => {
const current = state.outputs[executionId] || [];
@@ -172,9 +412,15 @@ export const useCliStreamStore = create<CliStreamState>()(
removeExecution: (executionId: string) => {
set((state) => {
const newExecutions = { ...state.executions };
const newBlocks = { ...state.blocks };
const newLastUpdate = { ...state.lastUpdate };
delete newExecutions[executionId];
delete newBlocks[executionId];
delete newLastUpdate[executionId];
return {
executions: newExecutions,
blocks: newBlocks,
lastUpdate: newLastUpdate,
currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId,
};
}, false, 'cliStream/removeExecution');
@@ -183,6 +429,65 @@ export const useCliStreamStore = create<CliStreamState>()(
setCurrentExecution: (executionId: string | null) => {
set({ currentExecutionId: executionId }, false, 'cliStream/setCurrentExecution');
},
// Block cache methods
getBlocks: (executionId: string) => {
const state = get();
const execution = state.executions[executionId];
// Return empty array if execution doesn't exist
if (!execution) {
return [];
}
// Check if cache is valid
// Cache is valid if:
// 1. Cache exists and has blocks
// 2. Execution has ended (has endTime)
// 3. Cache was updated after or at the execution end time
const cachedBlocks = state.blocks[executionId];
const lastUpdateTime = state.lastUpdate[executionId];
const isCacheValid =
cachedBlocks &&
lastUpdateTime &&
execution.endTime &&
lastUpdateTime >= execution.endTime;
// Return cached blocks if valid
if (isCacheValid) {
return cachedBlocks;
}
// Recompute blocks from output
const newBlocks = groupLinesIntoBlocks(execution.output, executionId, execution.status);
// Update cache
set((state) => ({
blocks: {
...state.blocks,
[executionId]: newBlocks,
},
lastUpdate: {
...state.lastUpdate,
[executionId]: Date.now(),
},
}), false, 'cliStream/updateBlockCache');
return newBlocks;
},
invalidateBlocks: (executionId: string) => {
set((state) => {
const newBlocks = { ...state.blocks };
const newLastUpdate = { ...state.lastUpdate };
delete newBlocks[executionId];
delete newLastUpdate[executionId];
return {
blocks: newBlocks,
lastUpdate: newLastUpdate,
};
}, false, 'cliStream/invalidateBlocks');
},
}),
{ name: 'CliStreamStore' }
)

View File

@@ -11,6 +11,8 @@ import type {
Toast,
WebSocketStatus,
WebSocketMessage,
NotificationAction,
ActionState,
} from '../types/store';
import type { SurfaceUpdate } from '../packages/a2ui-runtime/core/A2UITypes';
@@ -77,6 +79,9 @@ const initialState: NotificationState = {
// Current question dialog state
currentQuestion: null,
// Action state tracking
actionStates: new Map<string, ActionState>(),
};
export const useNotificationStore = create<NotificationStore>()(
@@ -248,6 +253,115 @@ export const useNotificationStore = create<NotificationStore>()(
saveToStorage(state.persistentNotifications);
},
// ========== Read Status Management ==========
toggleNotificationRead: (id: string) => {
set(
(state) => {
// Check both toasts and persistentNotifications
const toastIndex = state.toasts.findIndex((t) => t.id === id);
const persistentIndex = state.persistentNotifications.findIndex((n) => n.id === id);
if (toastIndex === -1 && persistentIndex === -1) {
return state; // Notification not found
}
const newState = { ...state };
if (toastIndex !== -1) {
const newToasts = [...state.toasts];
newToasts[toastIndex] = {
...newToasts[toastIndex],
read: !newToasts[toastIndex].read,
};
newState.toasts = newToasts;
}
if (persistentIndex !== -1) {
const newPersistent = [...state.persistentNotifications];
newPersistent[persistentIndex] = {
...newPersistent[persistentIndex],
read: !newPersistent[persistentIndex].read,
};
newState.persistentNotifications = newPersistent;
// Save to localStorage for persistent notifications
saveToStorage(newPersistent);
}
return newState;
},
false,
'toggleNotificationRead'
);
},
// ========== Action State Management ==========
setActionState: (actionKey: string, actionState: ActionState) => {
set(
(state) => {
const newActionStates = new Map(state.actionStates);
newActionStates.set(actionKey, actionState);
return { actionStates: newActionStates };
},
false,
'setActionState'
);
},
executeAction: async (action: NotificationAction, notificationId: string, actionKey?: string) => {
const key = actionKey || `${notificationId}-${action.label}`;
const state = get();
// Check if action is disabled
const currentActionState = state.actionStates.get(key);
if (currentActionState?.status === 'loading' || action.disabled) {
return;
}
// Set loading state
const newActionStates = new Map(state.actionStates);
newActionStates.set(key, {
status: 'loading',
lastAttempt: new Date().toISOString(),
});
set({ actionStates: newActionStates });
try {
await action.onClick();
// Set success state
const successStates = new Map(get().actionStates);
successStates.set(key, {
status: 'success',
lastAttempt: new Date().toISOString(),
});
set({ actionStates: successStates });
} catch (error) {
// Set error state
const errorStates = new Map(get().actionStates);
errorStates.set(key, {
status: 'error',
error: error instanceof Error ? error.message : String(error),
lastAttempt: new Date().toISOString(),
});
set({ actionStates: errorStates });
}
},
retryAction: async (actionKey: string, notificationId: string) => {
const state = get();
const actionState = state.actionStates.get(actionKey);
if (!actionState) {
console.warn(`[NotificationStore] No action state found for key: ${actionKey}`);
return;
}
// Reset to idle and let executeAction handle it
get().setActionState(actionKey, { status: 'idle', lastAttempt: new Date().toISOString() });
// Note: The caller should re-invoke executeAction with the original action
// This method just resets the state for retry
},
// ========== A2UI Actions ==========
addA2UINotification: (surface: SurfaceUpdate, title = 'A2UI Surface') => {

View File

@@ -4,7 +4,7 @@
// Manages workflow sessions, tasks, and related data
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { devtools, persist } from 'zustand/middleware';
import type {
WorkflowStore,
WorkflowState,
@@ -60,8 +60,9 @@ const initialState: WorkflowState = {
export const useWorkflowStore = create<WorkflowStore>()(
devtools(
(set, get) => ({
...initialState,
persist(
(set, get) => ({
...initialState,
// ========== Session Actions ==========
@@ -510,7 +511,49 @@ export const useWorkflowStore = create<WorkflowStore>()(
getSessionByKey: (key: string) => {
return get().sessionDataStore[key];
},
}),
}),
{
name: 'ccw-workflow-store',
version: 1, // State version for migration support
partialize: (state) => ({
projectPath: state.projectPath,
}),
migrate: (persistedState, version) => {
// Migration logic for future state shape changes
if (version < 1) {
// No migrations needed for initial version
// Example: if (version === 0) { persistedState.newField = defaultValue; }
}
return persistedState as typeof persistedState;
},
onRehydrateStorage: () => {
// Only log in development to avoid noise in production
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.log('[WorkflowStore] Hydrating from localStorage...');
}
return (state, error) => {
if (error) {
// eslint-disable-next-line no-console
console.error('[WorkflowStore] Rehydration error:', error);
return;
}
if (state?.projectPath) {
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.log('[WorkflowStore] Found persisted projectPath, re-initializing workspace:', state.projectPath);
}
// Use setTimeout to ensure the store is fully initialized before calling switchWorkspace
setTimeout(() => {
if (state.switchWorkspace) {
state.switchWorkspace(state.projectPath);
}
}, 0);
}
};
},
}
),
{ name: 'WorkflowStore' }
)
);