diff --git a/docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md b/docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md new file mode 100644 index 0000000..a8a7c95 --- /dev/null +++ b/docs/DASHBOARD_LOG_POLLING_DOCUMENTATION.md @@ -0,0 +1,575 @@ +# Dashboard Log Polling and Rendering Documentation + +## Overview + +This documentation explains the complete flow of how dashboard messages (logs with `operationId`) are polled, processed, sorted, and rendered in the workflow dashboard. The system uses a hierarchical tree structure to display operations and their progress, with real-time updates through polling. + +## Architecture Flow + +The system follows this flow: + +1. **Polling Controller** (`workflowPollingController.js`) - Manages polling intervals and scheduling +2. **Data Layer** (`workflowData.js`) - Fetches data from API and routes logs to appropriate handlers +3. **Dashboard Processor** (`workflowUiRendererDashboard.js`) - Processes logs with `operationId` and builds hierarchical tree +4. **Dashboard Renderer** (`workflowUiRendererDashboard.js`) - Renders the hierarchical tree structure + +## Key Files + +- `workflowPollingController.js` - Centralized polling controller +- `workflowData.js` - API communication and data routing +- `workflowUiRendererDashboard.js` - Dashboard log processing and rendering +- `workflowCoordination.js` - State management coordination + +## Implementation Details + +### 1. Polling Mechanism + +**File**: `frontend_agents/public/js/modules/workflowPollingController.js` + +The polling controller uses a recursive `setTimeout` approach to create an infinite polling chain. This ensures continuous updates while preventing race conditions and rate limiting issues. + +#### Configuration + +- **Base interval**: 5 seconds (`baseInterval = 5000`) +- **Maximum interval**: 10 seconds (`maxInterval = 10000`) +- **Exponential backoff multiplier**: 1.5 +- **Concurrency prevention**: Uses `isPollInProgress` flag to prevent multiple simultaneous polls + +#### Key Methods + +**`startPolling(workflowId)`** +- Starts polling for a specific workflow +- Stops any existing polling before starting new one +- Sets `activeWorkflowId` and `isPolling` flag +- Executes immediate first poll (no delay) +- Validates workflow ID before starting + +**`doPolling()`** +- Executes one poll cycle asynchronously +- Prevents concurrent execution using `isPollInProgress` flag +- Calls `pollWorkflowData()` from `workflowData.js` +- Handles errors and implements exponential backoff on failures +- Self-schedules next poll using recursive `setTimeout` +- Validates workflow is still valid before scheduling next poll + +**`stopPolling()`** +- Stops all polling operations immediately +- Clears all scheduled timeouts +- Resets all state flags (`isPolling`, `isPollInProgress`, `activeWorkflowId`) +- Resets failure count + +**`pausePolling()` / `resumePolling()`** +- Temporarily pauses polling (e.g., during user interactions) +- Resumes polling after pause + +#### Polling Flow + +```javascript +startPolling(workflowId) + ↓ +doPolling() [immediate first poll] + ↓ +pollWorkflowData(workflowId) [async API call] + ↓ +setTimeout(() => doPolling(), interval) [schedule next poll] + ↓ +[recursive loop continues until stopped] +``` + +#### Error Handling + +- **Rate limiting (429 errors)**: Increases backoff more aggressively, stops polling after 5 consecutive rate limit errors +- **Network errors**: Logged but don't immediately stop polling (allows retry) +- **Workflow validation**: Checks if workflow is still valid before each poll cycle +- **Poll failures**: Exponential backoff increases interval up to `maxInterval` + +### 2. Data Fetching + +**File**: `frontend_agents/public/js/modules/workflowData.js` + +The `pollWorkflowData()` function orchestrates the data fetching process. + +#### API Calls + +The function makes two parallel API calls: + +1. **`api.getWorkflow(workflowId)`** - Fetches workflow status and metadata +2. **`api.getWorkflowChatData(workflowId, afterTimestamp)`** - Fetches unified chat data (messages, logs, stats) + +#### Incremental Polling + +- **First poll**: `afterTimestamp = null` → Fetches ALL historical data +- **Subsequent polls**: `afterTimestamp = workflowState.lastRenderedTimestamp` → Fetches only new items since last render +- **Timestamp tracking**: Uses `createdAt` timestamp from each item to track what's been rendered + +#### Data Processing + +The `processUnifiedChatData()` function processes items in chronological order: + +1. Routes each item based on `type` field: + - `'message'` → `processUnifiedMessage()` + - `'log'` → `processUnifiedLog()` + - `'stat'` → `processUnifiedStat()` + +2. Updates `lastRenderedTimestamp` after processing each item (ensures accurate incremental polling) + +3. Processes items sequentially to maintain chronological order + +#### Workflow Status Updates + +- Monitors workflow status changes +- Updates UI buttons and controls when status changes +- Handles special case: Ignores 'completed' status if workflow is in Round 2+ (prevents premature stopping) + +#### Polling Continuation Logic + +Polling continues based on workflow status: +- **'running'**: Continues polling +- **'completed'**: Continues polling temporarily to get final messages, then stops +- **'failed' / 'stopped'**: Stops polling immediately +- **Other statuses**: Stops polling + +### 3. Log Routing + +**File**: `frontend_agents/public/js/modules/workflowData.js` - `processUnifiedLog()` + +Logs are routed to different rendering areas based on the presence of `operationId`: + +#### Routing Logic + +```javascript +if (log.operationId) { + // Logs WITH operationId → Dashboard + processDashboardLogs([frontendLog]); +} else { + // Logs WITHOUT operationId → Unified Content Area + WorkflowCoordination.addLogEntry(frontendLog.message, frontendLog.type, frontendLog); +} +``` + +#### Log Format Conversion + +Backend `ChatLog` format is converted to frontend format: + +```javascript +{ + id: log.id, + message: log.message, + type: log.type || 'info', + timestamp: log.timestamp, + status: log.status || 'running', + progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined, + performance: log.performance, + operationId: log.operationId || null, + parentId: log.parentId || null +} +``` + +#### Key Points + +- **All logs are processed**: No duplicates are skipped (logs may contain progress updates) +- **Progress tracking**: Logs with `operationId` typically contain progress information +- **Hierarchical structure**: `parentId` field enables parent-child relationships between operations + +### 4. Dashboard Log Processing + +**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js` - `processDashboardLogs()` + +This function processes logs with `operationId` and builds the hierarchical tree structure. + +#### Processing Steps + +1. **Group by operationId** + - Creates or updates operation groups in `dashboardLogTree.operations` Map + - Each operation stores logs in a Map keyed by `logId` (ensures uniqueness) + +2. **Update operation metadata** + - Updates `parentId` if not set yet (from first log entry) + - Updates `latestProgress` when log contains progress value + - Updates `latestStatus` when log contains status value + +3. **Generate unique log IDs** + - Uses provided `log.id` if available + - Otherwise generates: `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` + - Ensures all progress updates are stored, even with same progress value + +4. **Build root operations list** + - Filters operations without `parentId` + - Stores in `dashboardLogTree.rootOperations` array + +5. **Trigger rendering** + - Calls `renderDashboard()` after processing all logs + +#### Data Structure + +```javascript +dashboardLogTree = { + operations: Map, // All logs for this operation + parentId: string | null, // Parent operation ID (if nested) + expanded: boolean, // UI expanded/collapsed state + latestProgress: number | null, // Most recent progress value + latestStatus: string | null // Most recent status value + }>, + rootOperations: string[], // Operation IDs without parent + logExpandedStates: Map, // Individual log expanded states + currentRound: number | null // Current workflow round +} +``` + +#### Important Behaviors + +- **All logs stored**: Every log with same `operationId` is stored (represents progress updates) +- **Latest values tracked**: `latestProgress` and `latestStatus` always reflect most recent state +- **Parent-child relationships**: Operations can nest via `parentId` field + +### 5. Sorting + +**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js` + +Multiple sorting mechanisms ensure consistent display order: + +#### Operation-Level Log Sorting + +**Location**: `renderOperationNode()` function, lines 169-173 + +Logs within an operation are sorted by timestamp in ascending order: + +```javascript +const logsArray = Array.from(operation.logs.values()).sort((a, b) => { + const tsA = a.timestamp || 0; + const tsB = b.timestamp || 0; + return tsA - tsB; // Ascending order (oldest first) +}); +``` + +**Purpose**: Ensures logs are displayed in chronological order within each operation. + +#### Child Operations Sorting + +**Location**: `getChildOperations()` function, line 453 + +Child operations are sorted alphabetically by `operationId`: + +```javascript +return Array.from(dashboardLogTree.operations.entries()) + .filter(([opId, op]) => op.parentId === parentId) + .map(([opId]) => opId) + .sort(); // Alphabetical sort for consistent ordering +``` + +**Purpose**: Provides consistent, predictable ordering of sibling operations. + +#### Timeline Sorting (Unified Content) + +**Location**: `workflowUiRenderer.js` - `renderUnifiedContent()` function + +Logs without `operationId` are combined with messages and sorted by timestamp: + +```javascript +timeline.sort((a, b) => a.timestamp - b.timestamp); +``` + +**Purpose**: Creates a unified chronological timeline of all non-dashboard content. + +#### Sorting Summary + +| Context | Sort Key | Order | Purpose | +|---------|----------|-------|---------| +| Logs within operation | `timestamp` | Ascending | Chronological display | +| Child operations | `operationId` | Alphabetical | Consistent ordering | +| Unified timeline | `timestamp` | Ascending | Chronological timeline | + +### 6. Rendering + +**File**: `frontend_agents/public/js/modules/workflowUiRendererDashboard.js` - `renderDashboard()` + +The rendering system creates a hierarchical tree structure with collapsible nodes and progress indicators. + +#### Hierarchical Structure + +- **Root operations**: Operations without `parentId` are rendered first +- **Child operations**: Operations with `parentId` matching a parent's `operationId` are nested +- **Single line per operation**: Each operation shows ONE line that updates with latest status/progress +- **All logs represented**: All logs with same `operationId` are represented by this single updating line + +#### Rendering Process + +**Step 1: `renderDashboard()`** +- Builds HTML from `dashboardLogTree` structure +- Handles empty state (no operations) +- Sets up event handlers for collapse/expand functionality + +**Step 2: `renderOperationNode(operationId, depth)`** (Recursive) +- Renders a single operation node +- Calculates indentation based on depth (8px per level) +- Determines if operation has child operations +- Gets latest log entry for operation name and type +- Calculates progress percentage (forces 100% when status is 'completed') +- Builds HTML for: + - Expand/collapse button (if has children) + - Operation icon (based on log type) + - Operation name (from latest log message) + - Status and progress percentage + - Progress bar (if progress available) +- Recursively renders child operations if expanded + +#### Visual Elements + +**Operation Header** +- Expand/collapse button (chevron icon) +- Operation icon (info/success/error/warning) +- Operation name (from latest log message) +- Status badge (running/completed/failed/etc.) +- Progress percentage (if available) + +**Progress Bar** +- Visual progress indicator +- Width based on progress percentage (0-100%) +- "completed" class when progress >= 100% +- Hidden if no progress value + +**Indentation** +- Root level (depth 0): No indentation +- Child levels: Indented via parent container padding (8px per level) +- Creates visual hierarchy + +#### State Management + +**Expanded/Collapsed State** +- Stored in `operation.expanded` boolean +- Toggled via `toggleOperationExpanded(operationId)` +- Persists during re-renders +- Controls visibility of child operations container + +**Event Handlers** +- `setupCollapseExpandHandlers()`: Sets up click handlers for expand buttons +- `setupLogCollapseExpandHandlers()`: Sets up handlers for log entry expansion +- Click handlers toggle expanded state and re-render dashboard + +#### Rendering Flow + +``` +renderDashboard() + ↓ +[For each root operation] + renderOperationNode(operationId, 0) + ↓ + [Build operation header HTML] + ↓ + [If has children and expanded] + [For each child operation] + renderOperationNode(childOperationId, depth) + ↓ + [Recursive rendering continues...] + ↓ +[Set innerHTML of dashboard container] + ↓ +[Setup event handlers] +``` + +#### Key Rendering Features + +1. **Progress Updates**: Operation line updates in-place as new logs arrive +2. **Status Changes**: Status badge updates when operation status changes +3. **Collapsible Tree**: Users can expand/collapse operation groups +4. **Visual Hierarchy**: Indentation shows parent-child relationships +5. **Latest State**: Always shows most recent log message, progress, and status + +## Data Structures + +### Dashboard Log Tree + +```javascript +{ + operations: Map, // All logs for this operation + parentId: string | null, // Parent operation ID + expanded: boolean, // UI expanded state + latestProgress: number | null, // Most recent progress (0-1) + latestStatus: string | null // Most recent status + }>, + rootOperations: string[], // Operation IDs without parent + logExpandedStates: Map, // Individual log expanded states + currentRound: number | null // Current workflow round +} +``` + +### Log Entry Format + +```javascript +{ + id: string, // Unique log ID + message: string, // Log message text + type: 'info' | 'success' | 'error' | 'warning', + timestamp: number, // Unix timestamp (seconds) + status: string, // Operation status + progress: number | null, // Progress value (0-1) or null + operationId: string | null, // Operation ID (null = unified content) + parentId: string | null // Parent operation ID (for nesting) +} +``` + +### Unified Chat Data Item + +```javascript +{ + type: 'message' | 'log' | 'stat', // Item type + item: { /* message/log/stat data */ }, + createdAt: number // Timestamp for sorting +} +``` + +## Key Features + +### 1. Incremental Polling + +- Uses `lastRenderedTimestamp` to fetch only new items +- First poll loads all historical data (`afterTimestamp = null`) +- Subsequent polls fetch incrementally (`afterTimestamp = lastRenderedTimestamp`) +- Reduces API load and improves performance + +### 2. Hierarchical Display + +- Operations can have parent-child relationships via `parentId` +- Visual indentation shows hierarchy +- Collapsible tree structure for better UX +- Supports unlimited nesting depth + +### 3. Progress Tracking + +- Shows progress bars for operations with progress values +- Updates in real-time as new logs arrive +- Forces 100% progress when status is 'completed' +- Displays status badges (running/completed/failed/etc.) + +### 4. Collapsible Tree + +- Users can expand/collapse operation groups +- Expand/collapse state persists during re-renders +- Click handlers on operation headers and expand buttons +- Smooth visual transitions + +### 5. Round Detection + +- Tracks current workflow round in `dashboardLogTree.currentRound` +- Clears dashboard when round changes (via `updateProgressFromMessage()`) +- Prevents mixing data from different workflow rounds + +### 6. Duplicate Prevention + +- Uses Map with `logId` keys to prevent duplicate entries +- Same log ID updates in place rather than creating duplicates +- Ensures unique log entries even with same progress value + +## Error Handling + +### Rate Limiting (429 Errors) + +- Detected in `pollWorkflowData()` and `doPolling()` +- Triggers exponential backoff with increased multiplier +- Stops polling after 5 consecutive rate limit errors +- Prevents API abuse + +### Network Errors + +- Logged but don't immediately stop polling +- Allows retry on transient network issues +- Controller handles backoff automatically +- Polling continues for recoverable errors + +### Rendering Errors + +- Don't stop polling (UI issue, not data issue) +- Logged for debugging +- Polling continues to get workflow status updates +- UI can recover on next successful render + +### Workflow Validation + +- `isWorkflowValid()` checks before each poll cycle +- Validates workflow state exists and matches active workflow +- Checks if polling is still enabled (`pollActive` flag) +- Stops polling if workflow is invalid + +## Performance Considerations + +### Polling Intervals + +- Base interval: 5 seconds (balanced between responsiveness and server load) +- Maximum interval: 10 seconds (prevents excessive backoff) +- Exponential backoff: Prevents overwhelming server during errors + +### Data Processing + +- Processes items sequentially to maintain chronological order +- Uses Maps for O(1) lookups when grouping operations +- Incremental polling reduces data transfer +- Timestamp-based filtering at API level + +### Rendering Optimization + +- Full re-render on each update (simplifies state management) +- Event handlers re-attached after each render +- HTML generation is efficient (string concatenation) +- Minimal DOM manipulation (innerHTML replacement) + +## Usage Examples + +### Starting Polling + +```javascript +import pollingController from './workflowPollingController.js'; + +// Start polling for a workflow +pollingController.startPolling('workflow-123'); +``` + +### Stopping Polling + +```javascript +// Stop polling +pollingController.stopPolling(); +``` + +### Processing Dashboard Logs + +```javascript +import { processDashboardLogs } from './workflowUiRendererDashboard.js'; + +// Process logs with operationId +const logs = [ + { + id: 'log-1', + message: 'Processing file...', + type: 'info', + timestamp: 1234567890, + status: 'running', + progress: 0.5, + operationId: 'op-123', + parentId: null + } +]; + +processDashboardLogs(logs); +``` + +### Clearing Dashboard + +```javascript +import { clearDashboard } from './workflowUiRendererDashboard.js'; + +// Clear dashboard (e.g., on workflow reset) +clearDashboard(true); // true = reset round tracking +``` + +## Related Documentation + +- `FRONTEND_ARCHITECTURE.md` - Overall frontend architecture +- `workflowCoordination.js` - State management coordination +- `workflowUiRenderer.js` - Unified content rendering + +## Conclusion + +The dashboard log polling and rendering system provides a robust, hierarchical display of workflow operations with real-time updates. The system efficiently handles incremental polling, sorts data chronologically, and renders a collapsible tree structure that scales to complex workflows with multiple nested operations. + diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 119943e..9bd3543 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -248,11 +248,62 @@ export async function fetchChatData( console.log('📤 fetchChatData request:', requestConfig); - const data = await request(requestConfig); + const data = await request(requestConfig); console.log('📥 fetchChatData response:', data); - // Ensure all arrays exist + // Handle unified items format: { items: [{ type: 'message'|'log'|'stat', item: {...}, createdAt: ... }] } + if (data.items && Array.isArray(data.items)) { + const messages: WorkflowMessage[] = []; + const logs: WorkflowLog[] = []; + const stats: WorkflowStats[] = []; + const documents: WorkflowDocument[] = []; + + data.items.forEach((item: any) => { + if (item.type === 'message') { + // Handle both formats: item.item or direct item data + const messageData = item.item || item; + if (messageData && (messageData.id || messageData.message)) { + messages.push(messageData); + } else { + console.warn('⚠️ Invalid message item:', item); + } + } else if (item.type === 'log') { + const logData = item.item || item; + if (logData) { + logs.push(logData); + } + } else if (item.type === 'stat') { + const statData = item.item || item; + if (statData) { + stats.push(statData); + } + } + // Documents might be in items or separate + if (item.type === 'document') { + const docData = item.item || item; + if (docData) { + documents.push(docData); + } + } + }); + + console.log('📦 Extracted from items:', { + messages: messages.length, + logs: logs.length, + stats: stats.length, + documents: documents.length + }); + + return { + messages, + logs, + stats, + documents: documents.length > 0 ? documents : (Array.isArray(data.documents) ? data.documents : []) + }; + } + + // Fallback to direct format: { messages: [], logs: [], stats: [] } return { messages: Array.isArray(data.messages) ? data.messages : [], logs: Array.isArray(data.logs) ? data.logs : [], diff --git a/src/components/UiComponents/Log/Log.module.css b/src/components/UiComponents/Log/Log.module.css index 7ea171c..9f9800f 100644 --- a/src/components/UiComponents/Log/Log.module.css +++ b/src/components/UiComponents/Log/Log.module.css @@ -26,47 +26,112 @@ justify-content: center; } -/* Round Group */ -.roundGroup { + +/* Dashboard Tree Styles */ +.dashboardSection { display: flex; flex-direction: column; gap: 12px; + margin-bottom: 16px; } -.roundHeader { + +.dashboardContainer { display: flex; flex-direction: column; + gap: 4px; + width: 100%; +} + +.dashboardContainer > .operationNode { + width: 100%; +} + +.operationNode { + display: flex; + flex-direction: column; + gap: 0; + position: relative; + margin-bottom: 2px; +} + +.operationNodeIndented { + border-left: 2px solid var(--color-border, #e0e0e0); + position: relative; +} + +.operationNodeIndented::before { + content: ''; + position: absolute; + left: -1px; + top: 0; + bottom: 0; + width: 2px; + background-color: var(--color-border, #e0e0e0); + opacity: 0.6; +} + +.operationRow { + display: flex !important; + flex-direction: row !important; + align-items: flex-start; + min-height: 32px; + width: 100%; +} + +.operationContent { + flex: 1; + display: flex !important; + flex-direction: column !important; + gap: 4px; + min-width: 0; + width: 100%; +} + +.operationHeader { + display: flex !important; + flex-direction: row !important; + align-items: center !important; gap: 8px; - padding: 12px 16px; + padding: 6px 12px; background-color: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--object-radius-small); - transition: background-color 0.2s ease, border-color 0.2s ease; -} - -.roundHeader.clickable { - cursor: pointer; - user-select: none; -} - -.roundHeader.clickable:hover { - background-color: var(--color-highlight-gray); - border-color: var(--color-primary); -} - -.roundHeaderLabel { - display: flex; - justify-content: space-between; - align-items: center; font-size: 13px; - font-weight: 600; + transition: background-color 0.2s ease, border-color 0.2s ease; + min-height: 32px; + flex: 1; + width: 100%; +} + +.expandButton { + background: none; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-gray); + transition: color 0.2s ease; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.expandButton:hover { color: var(--color-text); } -.collapseIcon { +.expandButtonSpacer { + width: 20px; display: inline-block; + flex-shrink: 0; +} + +.collapseIcon { font-size: 10px; - color: var(--color-gray); + display: inline-block; transition: transform 0.2s ease; } @@ -74,29 +139,233 @@ transform: rotate(-90deg); } -.roundLogs { - display: flex; - flex-direction: column; - gap: 8px; - padding-left: 16px; +.operationIcon { + font-size: 12px; + font-weight: bold; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--color-primary, #007bff); + color: white; + flex-shrink: 0; } -/* Dark theme support */ -[data-theme="dark"] .roundHeader { +.operationIcon[data-type="success"] { + background-color: var(--color-success, #28a745); + color: white; +} + +.operationIcon[data-type="error"] { + background-color: var(--color-error, #dc3545); + color: white; +} + +.operationIcon[data-type="warning"] { + background-color: var(--color-warning, #ffc107); + color: var(--color-text); +} + +.operationIcon[data-type="info"] { + background-color: var(--color-primary, #007bff); + color: white; +} + +.operationName { + flex: 1; + color: var(--color-text); + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusMessageTag { + font-size: 11px; + color: var(--color-gray); + font-weight: 400; + padding: 2px 6px; + background-color: var(--color-highlight-gray); + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; + margin-left: 8px; +} + +.operationTimestamp { + font-size: 11px; + color: var(--color-gray); + font-weight: 400; + font-family: monospace; + white-space: nowrap; + flex-shrink: 0; + margin-right: 8px; +} + +.statusBadge { + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + background-color: var(--color-highlight-gray); + color: var(--color-text); + white-space: nowrap; + flex-shrink: 0; +} + +.statusBadge.statusCompleted { + background-color: var(--color-success, #28a745); + color: white; +} + +.statusBadge.statusFailed { + background-color: var(--color-error, #dc3545); + color: white; +} + +.statusBadge.statusRunning { + background-color: var(--color-primary, #007bff); + color: white; +} + +.progressPercentage { + font-size: 11px; + color: var(--color-gray); + font-weight: 600; + min-width: 45px; + text-align: right; + flex-shrink: 0; +} + +.progressBarContainer { + height: 4px; + background-color: var(--color-highlight-gray); + border-radius: 2px; + overflow: hidden; + margin-top: 4px; +} + +.progressBar { + height: 100%; + background-color: var(--color-primary, #007bff); + transition: width 0.3s ease; +} + +.progressBar.progressCompleted { + background-color: var(--color-success, #28a745); +} + +.operationChildren { + display: flex; + flex-direction: column; + gap: 0; + position: relative; +} + +/* Log messages container */ +.operationLogsContainer { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.operationLogsList { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 0; + border-left: 1px solid var(--color-border); + margin-left: 12px; +} + +.logEntry { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 8px; + background-color: var(--color-surface); + border-radius: var(--object-radius-small); + border: 1px solid var(--color-border); +} + +.logEntryHeader { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + flex-wrap: wrap; +} + +.logTimestamp { + color: var(--color-gray); + font-weight: 500; + font-family: monospace; + flex-shrink: 0; +} + +.logEntryMessage { + font-size: 13px; + color: var(--color-text); + line-height: 1.4; + word-wrap: break-word; + flex: 1; + min-width: 0; +} + +.logProgress { + font-size: 10px; + color: var(--color-gray); + font-weight: 600; + flex-shrink: 0; +} + +/* Dark theme support for log entries */ +[data-theme="dark"] .logEntry { background-color: var(--color-surface-dark); border-color: var(--color-border-dark); } -[data-theme="dark"] .roundHeader.clickable:hover { - background-color: rgba(255, 255, 255, 0.05); - border-color: var(--color-primary); -} - -[data-theme="dark"] .roundHeaderLabel { - color: var(--color-text-dark); -} - -[data-theme="dark"] .collapseIcon { +[data-theme="dark"] .logTimestamp { color: var(--color-gray-dark); } +[data-theme="dark"] .logEntryMessage { + color: var(--color-text-dark); +} + +[data-theme="dark"] .operationLogsList { + border-left-color: var(--color-border-dark); +} + +/* Dark theme support for dashboard */ +[data-theme="dark"] .operationHeader { + background-color: var(--color-surface-dark); + border-color: var(--color-border-dark); +} + +[data-theme="dark"] .operationName { + color: var(--color-text-dark); +} + +[data-theme="dark"] .statusBadge { + background-color: rgba(255, 255, 255, 0.1); + color: var(--color-text-dark); +} + +[data-theme="dark"] .progressPercentage { + color: var(--color-gray-dark); +} + +[data-theme="dark"] .progressBarContainer { + background-color: rgba(255, 255, 255, 0.1); +} + +[data-theme="dark"] .operationNode[data-depth] { + border-left-color: var(--color-border-dark); +} + diff --git a/src/components/UiComponents/Log/Log.tsx b/src/components/UiComponents/Log/Log.tsx index 6b5cb2a..548888e 100644 --- a/src/components/UiComponents/Log/Log.tsx +++ b/src/components/UiComponents/Log/Log.tsx @@ -1,115 +1,248 @@ -import React, { useMemo, useState, useEffect } from 'react'; -import { LogProps, RoundGroup } from './LogTypes'; -import { formatUnixTimestamp } from '../../../utils/time'; +import React from 'react'; +import { LogProps, WorkflowLog } from './LogTypes'; import { AutoScroll } from '../AutoScroll'; -import { LogMessage } from './LogMessage/LogMessage'; +import { formatUnixTimestamp } from '../../../utils/time'; import styles from './Log.module.css'; -// Helper function to group logs by round -const groupLogsByRound = (logs: any[]): RoundGroup[] => { - const roundMap = new Map(); - let currentRound = 1; // Track current round - - // Sort logs chronologically first - const sortedLogs = [...logs].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); - - sortedLogs.forEach((log) => { - const message = (log.message || '').toLowerCase(); - - // Check if this is a workflow status message that indicates a round change - if (message.includes('workflow started') || message.includes('workflow resumed')) { - const roundMatch = message.match(/\(?round\s+(\d+)\)?/i); - if (roundMatch) { - currentRound = parseInt(roundMatch[1], 10); - } else if (message.includes('workflow started')) { - // If started without round number, assume round 1 - currentRound = 1; - } - // If resumed without round number, keep current round - } - - // Assign log to current round - const roundNumber = currentRound; - - if (!roundMap.has(roundNumber)) { - roundMap.set(roundNumber, { - round: roundNumber, - logs: [], - latestProgress: undefined, - latestTimestamp: 0 - }); - } - - const roundGroup = roundMap.get(roundNumber)!; - roundGroup.logs.push(log); - - // Update latest progress and timestamp - if (log.progress !== undefined && log.progress !== null) { - if (roundGroup.latestProgress === undefined || log.progress > roundGroup.latestProgress) { - roundGroup.latestProgress = log.progress; - } - } - - if ((log.timestamp || 0) > roundGroup.latestTimestamp) { - roundGroup.latestTimestamp = log.timestamp || 0; - } - }); - - // Sort rounds and logs within each round - return Array.from(roundMap.values()) - .sort((a, b) => a.round - b.round) - .map(roundGroup => ({ - ...roundGroup, - logs: roundGroup.logs.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)) - })); +// Helper to get status badge class +const getStatusBadgeClass = (status?: string | null): string => { + if (!status) return styles.statusBadge; + switch (status.toLowerCase()) { + case 'completed': + return `${styles.statusBadge} ${styles.statusCompleted}`; + case 'failed': + case 'error': + return `${styles.statusBadge} ${styles.statusFailed}`; + case 'running': + return `${styles.statusBadge} ${styles.statusRunning}`; + default: + return styles.statusBadge; + } }; const Log: React.FC = ({ className = '', emptyMessage = 'No log information available', - logs = [] + dashboardTree, + onToggleOperationExpanded, + getChildOperations }) => { - // Group logs by round - const roundGroups = useMemo(() => groupLogsByRound(logs), [logs]); - - // Get the latest round number - const latestRound = roundGroups.length > 0 ? roundGroups[roundGroups.length - 1].round : null; - - // State to track collapsed rounds (round number -> isCollapsed) - const [collapsedRounds, setCollapsedRounds] = useState>(new Set()); - - // Initialize collapsed state: collapse all rounds except the latest one - useEffect(() => { - if (roundGroups.length > 0 && latestRound !== null) { - setCollapsedRounds(prev => { - const newSet = new Set(prev); - // Ensure latest round is not collapsed - newSet.delete(latestRound); - // Collapse all other rounds that aren't already in the set - roundGroups.forEach(rg => { - if (rg.round !== latestRound && !newSet.has(rg.round)) { - newSet.add(rg.round); - } - }); - return newSet; + const formatLogTimestamp = (timestamp: number): string => { + try { + const formatted = formatUnixTimestamp(timestamp, undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false }); + return formatted.time; + } catch { + return new Date(timestamp * 1000).toLocaleString(); } - }, [roundGroups.length, latestRound]); // Only update when rounds change, not on every log update - - // Toggle collapse state for a round - const toggleRoundCollapse = (round: number) => { - setCollapsedRounds(prev => { - const newSet = new Set(prev); - if (newSet.has(round)) { - newSet.delete(round); - } else { - newSet.add(round); - } - return newSet; + }; + // Render operation node recursively + const renderOperationNode = (operationId: string, depth: number = 0): React.ReactNode => { + if (!dashboardTree || !getChildOperations) { + return null; + } + + const operation = dashboardTree.operations.get(operationId); + if (!operation) { + return null; + } + + // Get logs for this operation, sorted by timestamp + const logsArray = Array.from(operation.logs.values()).sort((a, b) => { + const tsA = a.timestamp || 0; + const tsB = b.timestamp || 0; + return tsA - tsB; // Ascending order (oldest first) }); + + // Get latest log for timestamp + const latestLog = logsArray.length > 0 ? logsArray[logsArray.length - 1] : null; + + // Skip rendering if no logs yet + if (logsArray.length === 0) { + return null; + } + + // Get child operations + const childOperations = getChildOperations(operationId); + const hasChildren = childOperations.length > 0; + const hasLogs = logsArray.length > 0; + const hasContentToExpand = hasChildren || hasLogs; + + // Calculate progress percentage + let progressPercentage = 0; + if (operation.latestProgress !== null && operation.latestProgress !== undefined) { + progressPercentage = Math.min(Math.max(operation.latestProgress * 100, 0), 100); + } + + // Force 100% progress when status is 'completed' + if (operation.latestStatus === 'completed') { + progressPercentage = 100; + } + + // Use stable operation name (from first log) or fallback to operationId + const operationName = operation.operationName || `Operation ${operationId}`; + // Use latest message as status tag (updates with each poll) + const latestMessage = operation.latestMessage || ''; + const operationStatus = operation.latestStatus || 'running'; + const operationTimestamp = latestLog?.timestamp; + + // Calculate consistent indentation per level (24px per level) + const indentPx = depth * 24; + const hasIndent = depth > 0; + + // Calculate log entry indentation to align with operation name + // Operation name starts at: header padding-left (12px) + button/spacer (20px) + gap (8px) = 40px from operationContent + // operationLogsList has margin-left: 12px (for border), so log entries are at: container marginLeft + 12px + // We want log entries at 40px from operationContent, so: container marginLeft + 12px = 40px + // Therefore: container marginLeft = 28px from operationContent + // But operationNode has paddingLeft: 12px for indented nodes, 0 for root + // So from operationNode: container marginLeft = 28px - operationNode.paddingLeft + // Root: 28px - 0 = 28px + // Indented: 28px - 12px = 16px + const logIndentPx = hasIndent ? 16 : 28; + + return ( +
+
+ {/* Operation content */} +
+
+ {hasContentToExpand && ( + + )} + {!hasContentToExpand && } + + {operationName} + + {/* Latest status message tag (updates with each poll) */} + {latestMessage && ( + + {latestMessage} + + )} + + {operationTimestamp && ( + + {formatLogTimestamp(operationTimestamp)} + + )} + + + {operationStatus} + + + {progressPercentage > 0 && ( + + {Math.round(progressPercentage)}% + + )} +
+ + {progressPercentage > 0 && ( +
+
= 100 ? styles.progressCompleted : ''}`} + style={{ width: `${progressPercentage}%` }} + /> +
+ )} +
+
+ + {/* Show logs and children when expanded */} + {operation.expanded && ( + <> + {/* Log messages for this operation - show only latest log */} + {latestLog && ( +
+
+
+
+ + {formatLogTimestamp(latestLog.timestamp)} + + + {latestLog.message} + + {latestLog.status && ( + + {latestLog.status} + + )} + {latestLog.progress !== undefined && latestLog.progress !== null && ( + + {Math.round(latestLog.progress * 100)}% + + )} +
+
+
+
+ )} + + {/* Child operations */} + {hasChildren && ( +
+ {childOperations.map((childOpId) => renderOperationNode(childOpId, depth + 1))} +
+ )} + + )} +
+ ); }; - if (logs.length === 0) { + // Render dashboard tree + const renderDashboard = (): React.ReactNode => { + if (!dashboardTree || !getChildOperations) { + return null; + } + + if (dashboardTree.rootOperations.length === 0) { + return ( +
{emptyMessage}
+ ); + } + + return ( +
+ {dashboardTree.rootOperations.map((rootOpId) => renderOperationNode(rootOpId, 0))} +
+ ); + }; + + // Check if we have dashboard logs to display + const hasDashboardLogs = dashboardTree && dashboardTree.rootOperations.length > 0; + + if (!hasDashboardLogs) { return (
{emptyMessage}
@@ -119,65 +252,11 @@ const Log: React.FC = ({ return (
- {/* Scrollable Content Section - All Rounds in Chronological Order */} - +
- {/* All Round Groups - In Chronological Order (Oldest First, Latest Last) */} - {roundGroups.map((roundGroup) => { - const isCollapsed = collapsedRounds.has(roundGroup.round); - - return ( -
- {/* Round Header - Clickable */} - {roundGroup.logs.length > 0 && ( -
toggleRoundCollapse(roundGroup.round)} - > -
- Round {roundGroup.round} Logs - - ▼ - -
-
- )} - - {/* Log Messages for this Round - Collapsible */} - {!isCollapsed && ( -
- {roundGroup.logs.map((log, index) => { - // Convert log to Message format for LogMessage component - const message = { - id: log.id || `log-${index}`, - workflowId: log.workflowId || '', - message: log.message || '', - status: log.status, - timestamp: log.timestamp, - publishedAt: log.timestamp, - sequenceNr: index, - role: 'system', - documents: undefined, - summary: undefined - }; - - return ( - - ); - })} -
- )} +
+ {renderDashboard()}
- ); - })}
@@ -185,4 +264,3 @@ const Log: React.FC = ({ }; export default Log; - diff --git a/src/components/UiComponents/Log/LogTypes.ts b/src/components/UiComponents/Log/LogTypes.ts index bb6cd7d..4eb6a51 100644 --- a/src/components/UiComponents/Log/LogTypes.ts +++ b/src/components/UiComponents/Log/LogTypes.ts @@ -17,13 +17,21 @@ export interface WorkflowLog { } /** - * Round group containing logs and progress + * Dashboard log tree structure */ -export interface RoundGroup { - round: number; - logs: WorkflowLog[]; - latestProgress: number | undefined; - latestTimestamp: number; +export interface DashboardLogTree { + operations: Map; + parentId: string | null; + expanded: boolean; + latestProgress: number | null; + latestStatus: string | null; + operationName: string | null; + latestMessage: string | null; + }>; + rootOperations: string[]; + logExpandedStates: Map; + currentRound: number | null; } /** @@ -42,8 +50,18 @@ export interface LogProps { emptyMessage?: string; /** - * Array of log entries to display + * Dashboard log tree (logs with operationId) */ - logs?: WorkflowLog[]; + dashboardTree?: DashboardLogTree; + + /** + * Callback to toggle operation expanded state + */ + onToggleOperationExpanded?: (operationId: string) => void; + + /** + * Function to get child operations for a parent + */ + getChildOperations?: (parentId: string | null) => string[]; } diff --git a/src/core/PageManager/PageRenderer.tsx b/src/core/PageManager/PageRenderer.tsx index 7ed59b1..eb7cac3 100644 --- a/src/core/PageManager/PageRenderer.tsx +++ b/src/core/PageManager/PageRenderer.tsx @@ -1393,12 +1393,16 @@ const PageRenderer: React.FC = ({ case 'log': { const logConfig = content.logConfig || {}; - const logEntries = Array.isArray(hookData?.logs) ? hookData.logs : []; + const dashboardTree = hookData?.dashboardTree; + const onToggleOperationExpanded = hookData?.onToggleOperationExpanded; + const getChildOperations = hookData?.getChildOperations; return (
); diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts index 8ce1356..9d0221d 100644 --- a/src/hooks/playground/useDashboardInputForm.ts +++ b/src/hooks/playground/useDashboardInputForm.ts @@ -9,6 +9,7 @@ import { deleteFileFromMessageApi } from '../../api/workflowApi'; import type { Workflow, WorkflowMessage } from '../../api/workflowApi'; import { useWorkflowLifecycle } from './useWorkflowLifecycle'; import { useWorkflows } from './useWorkflows'; +import { useDashboardLogTree } from './useDashboardLogTree'; import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils'; export interface WorkflowFile { @@ -45,12 +46,24 @@ export function useDashboardInputForm() { startingWorkflow, messages, logs, + dashboardLogs, + unifiedContentLogs, startWorkflow, stopWorkflow, resetWorkflow, selectWorkflow, setWorkflowStatusOptimistic } = useWorkflowLifecycle(); + + // Dashboard log tree hook + const { + tree: dashboardTree, + processDashboardLogs, + clearDashboard, + toggleOperationExpanded, + updateCurrentRound, + getChildOperations + } = useDashboardLogTree(); // Ref to prevent infinite sync loops const isSyncingRef = useRef(false); @@ -134,6 +147,60 @@ export function useDashboardInputForm() { }, []); const { workflows, loading: workflowsLoading, refetch: refetchWorkflows } = useWorkflows(); + + // Track processed log IDs to avoid reprocessing + const processedLogIdsRef = useRef>(new Set()); + const lastWorkflowIdRef = useRef(null); + const lastDashboardLogsLengthRef = useRef(0); + + // Clear processed logs when workflow changes + useEffect(() => { + if (workflowId !== lastWorkflowIdRef.current) { + processedLogIdsRef.current.clear(); + lastWorkflowIdRef.current = workflowId || null; + lastDashboardLogsLengthRef.current = 0; + if (!workflowId) { + clearDashboard(true); + } + } + }, [workflowId, clearDashboard]); + + // Process dashboard logs when they change (only new logs) + useEffect(() => { + if (!dashboardLogs || dashboardLogs.length === 0) { + lastDashboardLogsLengthRef.current = 0; + return; + } + + // Only process if the array length changed (indicating new logs) + if (dashboardLogs.length === lastDashboardLogsLengthRef.current) { + return; + } + + // Filter to only new logs that haven't been processed + const newLogs = dashboardLogs.filter(log => { + const logId = log.id || `${log.operationId}-${log.timestamp}`; + if (processedLogIdsRef.current.has(logId)) { + return false; + } + processedLogIdsRef.current.add(logId); + return true; + }); + + // Only process if there are new logs + if (newLogs.length > 0) { + processDashboardLogs(newLogs); + } + + lastDashboardLogsLengthRef.current = dashboardLogs.length; + }, [dashboardLogs, processDashboardLogs]); + + // Update current round in dashboard tree when it changes + useEffect(() => { + if (currentRound !== undefined) { + updateCurrentRound(currentRound); + } + }, [currentRound, updateCurrentRound]); const workflowFiles = useMemo(() => { const fileMap = new Map(); @@ -657,7 +724,10 @@ export function useDashboardInputForm() { currentRound, isRunning, messages: displayMessages || [], - logs: logs || [], + logs: unifiedContentLogs || [], // Unified content logs (without operationId) + dashboardTree, // Dashboard log tree (logs with operationId) + onToggleOperationExpanded: toggleOperationExpanded, + getChildOperations, workflowItems, selectedWorkflowId: workflowId || selectedWorkflowId || null, onWorkflowSelect: handleWorkflowSelect, diff --git a/src/hooks/playground/useDashboardLogTree.ts b/src/hooks/playground/useDashboardLogTree.ts new file mode 100644 index 0000000..baf3633 --- /dev/null +++ b/src/hooks/playground/useDashboardLogTree.ts @@ -0,0 +1,197 @@ +import { useState, useCallback, useRef } from 'react'; +import { WorkflowLog } from '../../components/UiComponents/Log/LogTypes'; + +interface OperationData { + logs: Map; + parentId: string | null; + expanded: boolean; + latestProgress: number | null; + latestStatus: string | null; + operationName: string | null; // Stable name from first log + latestMessage: string | null; // Latest status message that updates +} + +interface DashboardLogTree { + operations: Map; + rootOperations: string[]; + logExpandedStates: Map; + currentRound: number | null; +} + +export function useDashboardLogTree() { + const [tree, setTree] = useState({ + operations: new Map(), + rootOperations: [], + logExpandedStates: new Map(), + currentRound: null + }); + + const treeRef = useRef(tree); + treeRef.current = tree; + + const generateLogId = useCallback((log: WorkflowLog): string => { + if (log.id) { + return log.id; + } + return `log_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + }, []); + + const processDashboardLogs = useCallback((logs: WorkflowLog[]) => { + setTree(prevTree => { + const newTree: DashboardLogTree = { + operations: new Map(prevTree.operations), + rootOperations: [...prevTree.rootOperations], + logExpandedStates: new Map(prevTree.logExpandedStates), + currentRound: prevTree.currentRound + }; + + // Process each log + logs.forEach(log => { + if (!log.operationId) { + return; // Skip logs without operationId + } + + const operationId = log.operationId; + const logId = generateLogId(log); + + // Get or create operation + const existingOperation = newTree.operations.get(operationId); + const isNewOperation = !existingOperation; + + // Create new logs Map (copy existing logs if updating) + const logsMap = existingOperation + ? new Map(existingOperation.logs) + : new Map(); + + // Store log (Map ensures uniqueness by logId) + logsMap.set(logId, log); + + // Determine stable operation name (only set once, never change) + // Always use formatted operationId as the stable name - don't use log messages + // Log messages are status updates and should go in latestMessage, not operationName + let operationName = existingOperation?.operationName || null; + if (operationName === null) { + // Format operationId by splitting on dashes/underscores and capitalizing + // This creates a stable, readable name like "Workflow Planning" from "workflow-planning" + const formattedName = operationId + .split(/[-_]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + operationName = formattedName || operationId; + } + + // Update latest message (for status tag) - this updates with each poll + const latestMessage = log.message || existingOperation?.latestMessage || null; + + // Update parentId if not set yet (from first log entry) + const parentId = existingOperation?.parentId !== null && existingOperation?.parentId !== undefined + ? existingOperation.parentId + : (log.parentId !== undefined && log.parentId !== null ? log.parentId : null); + + // Update latest progress (use latest value) + const latestProgress = log.progress !== undefined && log.progress !== null + ? log.progress + : existingOperation?.latestProgress ?? null; + + // Update latest status (use latest value) + const latestStatus = log.status !== undefined && log.status !== null + ? log.status + : existingOperation?.latestStatus ?? null; + + // Create new operation object to ensure React detects the change + const operation: OperationData = { + logs: logsMap, + parentId, + expanded: existingOperation?.expanded ?? false, + latestProgress, + latestStatus, + operationName, + latestMessage + }; + + newTree.operations.set(operationId, operation); + }); + + // Rebuild root operations list (operations without parentId) + // Use Set to ensure uniqueness, then convert back to array + const rootOpsSet = new Set(); + newTree.operations.forEach((op, opId) => { + if (op.parentId === null) { + rootOpsSet.add(opId); + } + }); + newTree.rootOperations = Array.from(rootOpsSet).sort(); // Alphabetical sort + + return newTree; + }); + }, [generateLogId]); + + const clearDashboard = useCallback((resetRound: boolean = false) => { + setTree({ + operations: new Map(), + rootOperations: [], + logExpandedStates: new Map(), + currentRound: resetRound ? null : treeRef.current.currentRound + }); + }, []); + + const toggleOperationExpanded = useCallback((operationId: string) => { + setTree(prevTree => { + const operation = prevTree.operations.get(operationId); + if (!operation) { + return prevTree; + } + + const newTree: DashboardLogTree = { + ...prevTree, + operations: new Map(prevTree.operations) + }; + + const updatedOperation = { + ...operation, + expanded: !operation.expanded + }; + + newTree.operations.set(operationId, updatedOperation); + + return newTree; + }); + }, []); + + const updateCurrentRound = useCallback((round: number | null) => { + setTree(prevTree => { + // Clear dashboard if round changes + if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) { + return { + operations: new Map(), + rootOperations: [], + logExpandedStates: new Map(), + currentRound: round + }; + } + + return { + ...prevTree, + currentRound: round + }; + }); + }, []); + + const getChildOperations = useCallback((parentId: string | null): string[] => { + const currentTree = treeRef.current; + return Array.from(currentTree.operations.entries()) + .filter(([_, op]) => op.parentId === parentId) + .map(([opId]) => opId) + .sort(); // Alphabetical sort + }, []); + + return { + tree, + processDashboardLogs, + clearDashboard, + toggleOperationExpanded, + updateCurrentRound, + getChildOperations + }; +} + diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts index 508fbe0..7001996 100644 --- a/src/hooks/playground/useWorkflowLifecycle.ts +++ b/src/hooks/playground/useWorkflowLifecycle.ts @@ -1,14 +1,22 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useApiRequest } from '../useApi'; import { type Workflow, type WorkflowMessage, type WorkflowLog, type StartWorkflowRequest, - fetchWorkflow as fetchWorkflowApi + fetchWorkflow as fetchWorkflowApi, + fetchChatData } from '../../api/workflowApi'; import { useWorkflowOperations } from './useWorkflowOperations'; import { sortMessages, sortLogs } from './playgroundUtils'; +import { useWorkflowPolling } from './useWorkflowPolling'; + +interface UnifiedChatDataItem { + type: 'message' | 'log' | 'stat'; + item: WorkflowMessage | WorkflowLog | any; + createdAt: number; +} export function useWorkflowLifecycle() { const [workflowId, setWorkflowId] = useState(null); @@ -16,12 +24,20 @@ export function useWorkflowLifecycle() { const [currentRound, setCurrentRound] = useState(undefined); const [messages, setMessages] = useState([]); const [logs, setLogs] = useState([]); + const [dashboardLogs, setDashboardLogs] = useState([]); + const [unifiedContentLogs, setUnifiedContentLogs] = useState([]); const [statusChangedFromRunningAt, setStatusChangedFromRunningAt] = useState(null); const prevStatusRef = useRef('idle'); const statusRef = useRef('idle'); const statusChangedFromRunningAtRef = useRef(null); + const lastRenderedTimestampRef = useRef(null); const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations(); const { request } = useApiRequest(); + const pollingController = useWorkflowPolling(); + + // Store polling controller methods in refs to avoid dependency issues + const pollingControllerRef = useRef(pollingController); + pollingControllerRef.current = pollingController; // Helper to update status and track transitions const updateWorkflowStatus = useCallback((newStatus: string) => { @@ -45,7 +61,282 @@ export function useWorkflowLifecycle() { const setWorkflowStatusOptimistic = useCallback((status: string) => { updateWorkflowStatus(status); }, [updateWorkflowStatus]); - + + // Convert backend log format to frontend format + const convertLogToFrontendFormat = useCallback((log: any): WorkflowLog => { + return { + id: log.id, + workflowId: log.workflowId || workflowId || '', + message: log.message || '', + type: log.type || 'info', + timestamp: log.timestamp || log.createdAt || Date.now(), + status: log.status || 'running', + progress: log.progress !== undefined && log.progress !== null ? log.progress : undefined, + performance: log.performance, + operationId: log.operationId || null, + parentId: log.parentId || null + }; + }, [workflowId]); + + // Process unified chat data chronologically + const processUnifiedChatData = useCallback((chatData: { messages: WorkflowMessage[]; logs: WorkflowLog[]; stats: any[] }) => { + console.log('🔄 Processing unified chat data:', { + messages: chatData.messages?.length || 0, + logs: chatData.logs?.length || 0, + stats: chatData.stats?.length || 0 + }); + + // Build unified timeline of all items + const timeline: UnifiedChatDataItem[] = []; + + // Add messages + (chatData.messages || []).forEach((message: WorkflowMessage) => { + timeline.push({ + type: 'message', + item: message, + createdAt: message.publishedAt || message.timestamp || Date.now() + }); + }); + + // Add logs + (chatData.logs || []).forEach((log: any) => { + timeline.push({ + type: 'log', + item: log, + createdAt: log.timestamp || log.createdAt || Date.now() + }); + }); + + // Add stats (if needed) + (chatData.stats || []).forEach((stat: any) => { + timeline.push({ + type: 'stat', + item: stat, + createdAt: stat.timestamp || stat.createdAt || Date.now() + }); + }); + + console.log('📋 Timeline created with', timeline.length, 'items'); + + // Sort chronologically + timeline.sort((a, b) => a.createdAt - b.createdAt); + + // Process items sequentially to maintain chronological order + // Update lastRenderedTimestamp after processing all items (use latest timestamp) + if (timeline.length > 0) { + const latestTimestamp = timeline[timeline.length - 1].createdAt; + lastRenderedTimestampRef.current = latestTimestamp; + } + + // Use functional updates to avoid dependency on current state + setMessages(prevMessages => { + const newMessages: WorkflowMessage[] = [...prevMessages]; + let hasChanges = false; + let messagesAdded = 0; + let messagesUpdated = 0; + + timeline.forEach((item) => { + if (item.type === 'message') { + const message = item.item as WorkflowMessage; + + if (!message || !message.id) { + console.warn('⚠️ Invalid message in timeline:', message); + return; + } + + // Check if message already exists + const existingIndex = newMessages.findIndex(m => m.id === message.id); + if (existingIndex >= 0) { + // Always update existing message (don't compare, just update) + newMessages[existingIndex] = message; + hasChanges = true; + messagesUpdated++; + } else { + newMessages.push(message); + hasChanges = true; + messagesAdded++; + } + } + }); + + console.log(`📨 Messages: ${messagesAdded} added, ${messagesUpdated} updated, total: ${newMessages.length}`); + if (messagesAdded > 0 || messagesUpdated > 0) { + console.log('📨 Sample messages:', newMessages.slice(0, 3).map(m => ({ id: m.id, message: m.message?.substring(0, 50) }))); + } + + // Always return sorted array if we processed any messages + if (hasChanges || timeline.some(item => item.type === 'message')) { + const sorted = [...newMessages].sort(sortMessages); + console.log(`✅ Returning ${sorted.length} sorted messages`); + return sorted; + } + + console.log('⚠️ No changes detected, returning previous messages'); + return prevMessages; + }); + + setDashboardLogs(prevDashboardLogs => { + const newDashboardLogs: WorkflowLog[] = [...prevDashboardLogs]; + let hasChanges = false; + + timeline.forEach((item) => { + if (item.type === 'log') { + const backendLog = item.item as any; + const frontendLog = convertLogToFrontendFormat(backendLog); + + // Route logs based on operationId + if (frontendLog.operationId) { + // Logs WITH operationId → Dashboard + const existingIndex = newDashboardLogs.findIndex(l => l.id === frontendLog.id); + if (existingIndex >= 0) { + // Check if log actually changed + const existingLog = newDashboardLogs[existingIndex]; + if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) { + newDashboardLogs[existingIndex] = frontendLog; + hasChanges = true; + } + } else { + newDashboardLogs.push(frontendLog); + hasChanges = true; + } + } + } + }); + + // Only return new array if there are changes + if (!hasChanges) { + return prevDashboardLogs; + } + + return [...newDashboardLogs].sort(sortLogs); + }); + + setUnifiedContentLogs(prevUnifiedContentLogs => { + const newUnifiedContentLogs: WorkflowLog[] = [...prevUnifiedContentLogs]; + let hasChanges = false; + + timeline.forEach((item) => { + if (item.type === 'log') { + const backendLog = item.item as any; + const frontendLog = convertLogToFrontendFormat(backendLog); + + // Route logs based on operationId + if (!frontendLog.operationId) { + // Logs WITHOUT operationId → Unified Content Area + const existingIndex = newUnifiedContentLogs.findIndex(l => l.id === frontendLog.id); + if (existingIndex >= 0) { + // Check if log actually changed + const existingLog = newUnifiedContentLogs[existingIndex]; + if (JSON.stringify(existingLog) !== JSON.stringify(frontendLog)) { + newUnifiedContentLogs[existingIndex] = frontendLog; + hasChanges = true; + } + } else { + newUnifiedContentLogs.push(frontendLog); + hasChanges = true; + } + } + } + }); + + // Only return new array if there are changes + if (!hasChanges) { + return prevUnifiedContentLogs; + } + + return [...newUnifiedContentLogs].sort(sortLogs); + }); + + // Update combined logs for backward compatibility (using functional update) + setLogs(prevLogs => { + const allLogs: WorkflowLog[] = [...prevLogs]; + + timeline.forEach((item) => { + if (item.type === 'log') { + const backendLog = item.item as any; + const frontendLog = convertLogToFrontendFormat(backendLog); + const existingIndex = allLogs.findIndex(l => l.id === frontendLog.id); + if (existingIndex >= 0) { + allLogs[existingIndex] = frontendLog; + } else { + allLogs.push(frontendLog); + } + } + }); + + return [...allLogs].sort(sortLogs); + }); + }, [convertLogToFrontendFormat]); + + // Poll workflow data using unified chat data endpoint + const pollWorkflowData = useCallback(async (id: string) => { + try { + // Determine afterTimestamp for incremental polling + const afterTimestamp = lastRenderedTimestampRef.current || undefined; + + // Fetch workflow status + const workflowData = await fetchWorkflowApi(request, id).catch(() => null); + + if (workflowData) { + const status = workflowData.status || 'idle'; + const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined; + + updateWorkflowStatus(status); + setCurrentRound(round); + } + + // Fetch unified chat data + const chatData = await fetchChatData(request, id, afterTimestamp); + + console.log('📊 Processed chat data:', { + messagesCount: chatData.messages?.length || 0, + logsCount: chatData.logs?.length || 0, + statsCount: chatData.stats?.length || 0, + afterTimestamp: afterTimestamp + }); + + // If we got empty results and we're using afterTimestamp, the backend might be filtering incorrectly + // Reset timestamp to null so next poll fetches all items (but only if we have existing data) + const hasNoNewData = (chatData.messages?.length || 0) === 0 && + (chatData.logs?.length || 0) === 0 && + (chatData.stats?.length || 0) === 0; + + // Only reset if we're using afterTimestamp and got empty results + // This handles cases where backend filtering might miss items due to timestamp precision issues + if (hasNoNewData && afterTimestamp !== undefined && lastRenderedTimestampRef.current !== null) { + console.warn('⚠️ Got empty results with afterTimestamp, resetting timestamp for next poll'); + // Don't reset immediately - let this poll complete, next poll will fetch all + lastRenderedTimestampRef.current = null; + } + + // Process unified chat data + processUnifiedChatData(chatData); + + // Determine if polling should continue + const currentStatus = statusRef.current; + const changedAt = statusChangedFromRunningAtRef.current; + + // Stop polling immediately for failed or stopped workflows + // For completed workflows, allow grace period (handled by useEffect) + if (currentStatus === 'failed' || currentStatus === 'stopped') { + pollingControllerRef.current.stopPolling(); + return; + } + + // Continue polling for 'running' status + // For 'completed' status, continue if within grace period (handled by useEffect) + // Polling will be stopped by the useEffect when grace period expires or status changes to failed/stopped + } catch (error: any) { + // Handle rate limiting (429 errors) + if (error?.status === 429 || error?.response?.status === 429) { + throw error; // Let polling controller handle rate limit backoff + } + console.error('Error polling workflow data:', error); + // Don't throw for other errors - allow polling to continue with backoff + } + }, [request, updateWorkflowStatus, processUnifiedChatData]); + + // Load initial workflow data (non-polling) const loadWorkflowData = useCallback(async (id: string) => { try { const workflowData = await fetchWorkflowApi(request, id).catch(() => null); @@ -53,73 +344,107 @@ export function useWorkflowLifecycle() { if (!workflowData) { setMessages([]); setLogs([]); + setDashboardLogs([]); + setUnifiedContentLogs([]); return; } const messagesData = Array.isArray(workflowData.messages) ? workflowData.messages : []; const logsData = Array.isArray(workflowData.logs) ? workflowData.logs : []; const status = workflowData.status || 'idle'; + const round = workflowData.currentRound !== undefined ? workflowData.currentRound : undefined; - if (messagesData.length > 0) { - setMessages([...messagesData].sort(sortMessages)); - } else { - setMessages([]); - } - - if (logsData.length > 0) { - setLogs([...logsData].sort(sortLogs)); - } else { - setLogs([]); - } - - // Update status and track transitions updateWorkflowStatus(status); - } catch (error) { - } - }, [request, updateWorkflowStatus]); + setCurrentRound(round); + + // Always fetch unified chat data to get all messages and logs + // Reset lastRenderedTimestamp to fetch all historical data + lastRenderedTimestampRef.current = null; + try { + const chatData = await fetchChatData(request, id, undefined); + console.log('📥 loadWorkflowData: Fetched unified chat data:', { + messagesCount: chatData.messages?.length || 0, + logsCount: chatData.logs?.length || 0 + }); + processUnifiedChatData(chatData); + } catch (error) { + console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error); + // Fallback to workflowData if unified chat data fails + if (messagesData.length > 0) { + setMessages([...messagesData].sort(sortMessages)); + } + + // Process logs and separate by operationId + const dashboardLogsList: WorkflowLog[] = []; + const unifiedContentLogsList: WorkflowLog[] = []; + + logsData.forEach((log: any) => { + const frontendLog = convertLogToFrontendFormat(log); + if (frontendLog.operationId) { + dashboardLogsList.push(frontendLog); + } else { + unifiedContentLogsList.push(frontendLog); + } + }); + setDashboardLogs(dashboardLogsList.sort(sortLogs)); + setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs)); + setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs)); + } + } catch (error) { + console.error('Error loading workflow data:', error); + } + }, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]); + + // Set up polling when workflow is running useEffect(() => { if (!workflowId) { - setMessages([]); - setLogs([]); - setCurrentRound(undefined); - setStatusChangedFromRunningAt(null); - statusChangedFromRunningAtRef.current = null; + // Only clear state if not already cleared to avoid unnecessary updates + setMessages(prev => prev.length > 0 ? [] : prev); + setLogs(prev => prev.length > 0 ? [] : prev); + setDashboardLogs(prev => prev.length > 0 ? [] : prev); + setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev); + setCurrentRound(prev => prev !== undefined ? undefined : prev); + if (statusChangedFromRunningAt !== null) { + setStatusChangedFromRunningAt(null); + statusChangedFromRunningAtRef.current = null; + } + lastRenderedTimestampRef.current = null; + pollingControllerRef.current.stopPolling(); return; } // Continue polling if: // 1. Workflow is currently running, OR - // 2. Workflow just stopped running (within last 5 seconds) - grace period to catch final messages + // 2. Workflow just completed (within last 10 seconds) - grace period to catch final messages + // Stop polling for failed or stopped workflows immediately + // Use ref for statusChangedFromRunningAt to get latest value (state updates are async) + const changedAtRef = statusChangedFromRunningAtRef.current; const shouldPoll = workflowStatus === 'running' || - (statusChangedFromRunningAt !== null && Date.now() - statusChangedFromRunningAt < 5000); + (workflowStatus === 'completed' && changedAtRef !== null && Date.now() - changedAtRef < 10000); if (shouldPoll) { - // Load immediately when status becomes running or when in grace period - loadWorkflowData(workflowId); - - // Poll more frequently for smoother updates (every 1 second instead of 2) - const intervalId = setInterval(() => { - // Check grace period on each poll using refs to get latest values - const currentStatus = statusRef.current; - const changedAt = statusChangedFromRunningAtRef.current; - const stillInGracePeriod = currentStatus === 'running' || - (changedAt !== null && Date.now() - changedAt < 5000); - - if (stillInGracePeriod) { - loadWorkflowData(workflowId); - } - }, 1000); - - return () => { - clearInterval(intervalId); - }; + // Reset lastRenderedTimestamp for first poll (fetch all historical data) + if (lastRenderedTimestampRef.current === null) { + lastRenderedTimestampRef.current = null; // null means fetch all + } + + // Start polling + pollingControllerRef.current.startPolling(workflowId, pollWorkflowData); } else { - // Clear the status change timestamp when we stop polling - setStatusChangedFromRunningAt(null); - statusChangedFromRunningAtRef.current = null; + // Stop polling for failed, stopped, or completed (after grace period) workflows + pollingControllerRef.current.stopPolling(); + // Clear the status change timestamp when we stop polling (only if not already null) + if (statusChangedFromRunningAt !== null) { + setStatusChangedFromRunningAt(null); + statusChangedFromRunningAtRef.current = null; + } } - }, [workflowStatus, workflowId, loadWorkflowData, statusChangedFromRunningAt]); + + return () => { + pollingControllerRef.current.stopPolling(); + }; + }, [workflowStatus, workflowId, pollWorkflowData]); const handleStartWorkflow = useCallback(async ( workflowData: StartWorkflowRequest, @@ -132,6 +457,8 @@ export function useWorkflowLifecycle() { const workflow = result.data as Workflow; setWorkflowId(workflow.id); updateWorkflowStatus(workflow.status || 'running'); + // Reset lastRenderedTimestamp for new workflow + lastRenderedTimestampRef.current = null; return { success: true, data: result.data }; } else { return { success: false, error: result.error || 'Failed to start workflow' }; @@ -168,17 +495,23 @@ export function useWorkflowLifecycle() { setCurrentRound(undefined); setStatusChangedFromRunningAt(null); statusChangedFromRunningAtRef.current = null; + lastRenderedTimestampRef.current = null; + pollingControllerRef.current.stopPolling(); }, [updateWorkflowStatus]); const selectWorkflow = useCallback(async (workflowIdToSelect: string) => { try { setWorkflowId(workflowIdToSelect); + // Reset lastRenderedTimestamp for new workflow selection + lastRenderedTimestampRef.current = null; const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null); if (!workflowData) { setMessages([]); setLogs([]); + setDashboardLogs([]); + setUnifiedContentLogs([]); updateWorkflowStatus('idle'); return; } @@ -191,23 +524,46 @@ export function useWorkflowLifecycle() { updateWorkflowStatus(status); setCurrentRound(round); - if (messagesData.length > 0) { - setMessages([...messagesData].sort(sortMessages)); - } else { - setMessages([]); - } + // Always fetch unified chat data to get all messages and logs (regardless of status) + // This ensures completed workflows also show their logs + try { + const chatData = await fetchChatData(request, workflowIdToSelect, undefined); + console.log('📥 selectWorkflow: Fetched unified chat data:', { + messagesCount: chatData.messages?.length || 0, + logsCount: chatData.logs?.length || 0, + status + }); + processUnifiedChatData(chatData); + } catch (error) { + console.warn('⚠️ Failed to fetch unified chat data, falling back to workflowData:', error); + // Fallback to workflowData if unified chat data fails + if (messagesData.length > 0) { + setMessages([...messagesData].sort(sortMessages)); + } + + // Process logs and separate by operationId + const dashboardLogsList: WorkflowLog[] = []; + const unifiedContentLogsList: WorkflowLog[] = []; + + logsData.forEach((log: any) => { + const frontendLog = convertLogToFrontendFormat(log); + if (frontendLog.operationId) { + dashboardLogsList.push(frontendLog); + } else { + unifiedContentLogsList.push(frontendLog); + } + }); - if (logsData.length > 0) { - setLogs([...logsData].sort(sortLogs)); - } else { - setLogs([]); + setDashboardLogs(dashboardLogsList.sort(sortLogs)); + setUnifiedContentLogs(unifiedContentLogsList.sort(sortLogs)); + setLogs([...dashboardLogsList, ...unifiedContentLogsList].sort(sortLogs)); } - // If workflow is running, start polling immediately - // The useEffect will handle the polling setup + // If workflow is running, polling will start automatically via useEffect } catch (error) { + console.error('Error selecting workflow:', error); } - }, [request, updateWorkflowStatus]); + }, [request, updateWorkflowStatus, convertLogToFrontendFormat, processUnifiedChatData]); const isRunning = workflowStatus === 'running'; const isStopping = workflowId ? stoppingWorkflows.has(workflowId) : false; @@ -221,6 +577,8 @@ export function useWorkflowLifecycle() { startingWorkflow, messages, logs, + dashboardLogs, + unifiedContentLogs, startWorkflow: handleStartWorkflow, stopWorkflow: handleStopWorkflow, resetWorkflow, @@ -228,4 +586,3 @@ export function useWorkflowLifecycle() { setWorkflowStatusOptimistic }; } - diff --git a/src/hooks/playground/useWorkflowPolling.ts b/src/hooks/playground/useWorkflowPolling.ts new file mode 100644 index 0000000..403f75d --- /dev/null +++ b/src/hooks/playground/useWorkflowPolling.ts @@ -0,0 +1,205 @@ +import { useRef, useCallback } from 'react'; + +interface PollingState { + activeWorkflowId: string | null; + isPolling: boolean; + isPollInProgress: boolean; + isPaused: boolean; + currentInterval: number; + failureCount: number; + rateLimitFailureCount: number; + timeoutId: NodeJS.Timeout | null; +} + +const BASE_INTERVAL = 5000; // 5 seconds +const MAX_INTERVAL = 10000; // 10 seconds +const BACKOFF_MULTIPLIER = 1.5; +const RATE_LIMIT_BACKOFF_MULTIPLIER = 2.0; +const MAX_RATE_LIMIT_FAILURES = 5; + +export type PollCallback = (workflowId: string) => Promise; + +export function useWorkflowPolling() { + const stateRef = useRef({ + activeWorkflowId: null, + isPolling: false, + isPollInProgress: false, + isPaused: false, + currentInterval: BASE_INTERVAL, + failureCount: 0, + rateLimitFailureCount: 0, + timeoutId: null + }); + + const pollCallbackRef = useRef(null); + + const calculateInterval = useCallback((isRateLimit: boolean = false): number => { + const state = stateRef.current; + const multiplier = isRateLimit ? RATE_LIMIT_BACKOFF_MULTIPLIER : BACKOFF_MULTIPLIER; + const newInterval = Math.min( + BASE_INTERVAL * Math.pow(multiplier, state.failureCount), + MAX_INTERVAL + ); + return Math.floor(newInterval); + }, []); + + const scheduleNextPoll = useCallback((interval: number) => { + const state = stateRef.current; + + // Clear any existing timeout + if (state.timeoutId) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + } + + // Don't schedule if not polling or paused + if (!state.isPolling || state.isPaused || !state.activeWorkflowId) { + return; + } + + // Schedule next poll + state.timeoutId = setTimeout(() => { + state.timeoutId = null; + doPolling(); + }, interval); + }, []); + + const doPolling = useCallback(async () => { + const state = stateRef.current; + + // Prevent concurrent polls + if (state.isPollInProgress) { + return; + } + + // Validate workflow is still active + if (!state.activeWorkflowId || !state.isPolling || state.isPaused) { + return; + } + + const workflowId = state.activeWorkflowId; + state.isPollInProgress = true; + + try { + if (pollCallbackRef.current) { + await pollCallbackRef.current(workflowId); + } + + // Success - reset failure counts and interval + state.failureCount = 0; + state.rateLimitFailureCount = 0; + state.currentInterval = BASE_INTERVAL; + + // Schedule next poll + scheduleNextPoll(state.currentInterval); + } catch (error: any) { + // Handle errors + const isRateLimit = error?.status === 429 || error?.response?.status === 429; + + if (isRateLimit) { + state.rateLimitFailureCount++; + + // Stop polling after too many rate limit errors + if (state.rateLimitFailureCount >= MAX_RATE_LIMIT_FAILURES) { + console.error('Too many rate limit errors, stopping polling'); + stopPolling(); + return; + } + } else { + state.rateLimitFailureCount = 0; // Reset rate limit count on non-rate-limit errors + } + + state.failureCount++; + const nextInterval = calculateInterval(isRateLimit); + state.currentInterval = nextInterval; + + console.warn(`Polling error (attempt ${state.failureCount}):`, error); + + // Schedule next poll with backoff + scheduleNextPoll(nextInterval); + } finally { + state.isPollInProgress = false; + } + }, [scheduleNextPoll, calculateInterval]); + + const startPolling = useCallback((workflowId: string, callback: PollCallback) => { + const state = stateRef.current; + + // Stop any existing polling + if (state.isPolling) { + stopPolling(); + } + + // Validate workflow ID + if (!workflowId || typeof workflowId !== 'string') { + console.error('Invalid workflow ID for polling:', workflowId); + return; + } + + // Set up polling state + state.activeWorkflowId = workflowId; + state.isPolling = true; + state.isPaused = false; + state.failureCount = 0; + state.rateLimitFailureCount = 0; + state.currentInterval = BASE_INTERVAL; + pollCallbackRef.current = callback; + + // Execute immediate first poll (no delay) + doPolling(); + }, [doPolling]); + + const stopPolling = useCallback(() => { + const state = stateRef.current; + + // Clear timeout + if (state.timeoutId) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + } + + // Reset state + state.isPolling = false; + state.isPollInProgress = false; + state.activeWorkflowId = null; + state.failureCount = 0; + state.rateLimitFailureCount = 0; + state.currentInterval = BASE_INTERVAL; + state.isPaused = false; + pollCallbackRef.current = null; + }, []); + + const pausePolling = useCallback(() => { + const state = stateRef.current; + state.isPaused = true; + }, []); + + const resumePolling = useCallback(() => { + const state = stateRef.current; + if (state.isPolling && state.isPaused) { + state.isPaused = false; + // Resume polling immediately + if (!state.isPollInProgress) { + scheduleNextPoll(0); + } + } + }, [scheduleNextPoll]); + + const isPolling = useCallback((): boolean => { + return stateRef.current.isPolling && !stateRef.current.isPaused; + }, []); + + const getActiveWorkflowId = useCallback((): string | null => { + return stateRef.current.activeWorkflowId; + }, []); + + return { + startPolling, + stopPolling, + pausePolling, + resumePolling, + isPolling, + getActiveWorkflowId + }; +} + diff --git a/src/styles/pages.module.css b/src/styles/pages.module.css index 2c40905..fc40b69 100644 --- a/src/styles/pages.module.css +++ b/src/styles/pages.module.css @@ -261,7 +261,7 @@ grid-column: 1 / -1; grid-row: 1; display: grid; - grid-template-columns: 1fr 400px; + grid-template-columns: 2fr 1fr; gap: 1rem; min-height: 0; overflow: hidden;