mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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') => {
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user