diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b2b2768..fc8e13e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"bd-123","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 16700s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-03T09:01:50.527254677Z","created_by":"coder","updated_at":"2026-03-03T09:04:19.266904698Z","closed_at":"2026-03-03T09:04:19.266841038Z","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":12,"issue_id":"bd-123","author":"Jed Arden","text":"Alternative analysis: Work IS available (22 beads in ready-queue.json). This HUMAN bead is a false positive - workers should read ready-queue.json directly. Propose closure.","created_at":"2026-03-03T09:04:19Z"}]} -{"id":"bd-129","title":"Add blessed TUI tests for ActivityStream component","description":"Add unit tests for src/tui/components/ActivityStream.ts using blessed testing patterns.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-03T14:28:18.913405189Z","created_by":"coder","updated_at":"2026-03-03T14:28:18.913405189Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2","testing","tui"]} +{"id":"bd-129","title":"Add blessed TUI tests for ActivityStream component","description":"Add unit tests for src/tui/components/ActivityStream.ts using blessed testing patterns.","status":"in_progress","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T14:28:18.913405189Z","created_by":"coder","updated_at":"2026-03-03T15:28:26.238254731Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2","testing","tui"]} {"id":"bd-13y","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 17238s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-03T09:10:48.326406226Z","created_by":"coder","updated_at":"2026-03-03T09:15:00.935446905Z","closed_at":"2026-03-03T09:15:00.935230202Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"bd-15h","title":"ALT-002: Integrate br-ready-wrapper into worker discovery","description":"For HUMAN bead bd-1sw. Update worker scripts to use br-ready-wrapper.sh as fallback when br ready fails. Makes workers resilient to br CLI bugs.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-03T08:22:33.670849945Z","created_by":"coder","updated_at":"2026-03-03T08:39:07.100724785Z","closed_at":"2026-03-03T08:39:07.100557647Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["br","resilience","worker"],"comments":[{"id":7,"issue_id":"bd-15h","author":"Jed Arden","text":"Implemented ready-queue.json workaround (.beads/ready-queue.json) which workers can read directly. The wrapper script already exists at scripts/br-ready-wrapper.sh. Workers should check: 1) .beads/ready-queue.json first, 2) scripts/br-ready-wrapper.sh --json second, 3) br ready last (with fallback).","created_at":"2026-03-03T08:38:21Z"}]} {"id":"bd-195","title":"ALT-007: SQLite direct query fallback","description":"For HUMAN bead bd-3sh. Query beads.db directly using sqlite3 or Node.js better-sqlite3. Bypasses br CLI entirely. Requires sqlite3 CLI or npm package. Fastest access but tight coupling to schema.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-03T08:39:58.775979286Z","created_by":"coder","updated_at":"2026-03-03T10:33:32.997760049Z","closed_at":"2026-03-03T10:33:31.799597115Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["alternative","br","resilience","worker"],"comments":[{"id":32,"issue_id":"bd-195","author":"Jed Arden","text":"No longer needed - br v0.1.20 fixes the schema bug natively.","created_at":"2026-03-03T10:33:32Z"}]} diff --git a/src/tui/components/ActivityStream.test.ts b/src/tui/components/ActivityStream.test.ts new file mode 100644 index 0000000..89a1307 --- /dev/null +++ b/src/tui/components/ActivityStream.test.ts @@ -0,0 +1,641 @@ +/** + * Tests for ActivityStream Component + * + * Tests the activity stream display with mocked blessed elements. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as blessed from 'blessed'; + +// Mock the blessed module before importing ActivityStream +vi.mock('blessed', () => { + // Create the mock log instance + const mockLogInstance = { + setContent: vi.fn(), + log: vi.fn(), + setLabel: vi.fn(), + focus: vi.fn(), + key: vi.fn(), + screen: { + render: vi.fn(), + }, + }; + + const mockLog = vi.fn(() => mockLogInstance); + + return { + default: { + log: mockLog, + }, + log: mockLog, + }; +}); + +// Import after mocking +import { ActivityStream, ActivityFilter } from './ActivityStream.js'; +import { LogEvent } from '../../types.js'; + +// Helper to create mock LogEvent +function createMockEvent(overrides: Partial = {}): LogEvent { + return { + ts: Date.now(), + worker: 'w-test123', + level: 'info', + msg: 'Test event message', + ...overrides, + }; +} + +// Helper to create mock screen +function createMockScreen() { + return { + render: vi.fn(), + append: vi.fn(), + key: vi.fn(), + destroy: vi.fn(), + } as unknown as blessed.Widgets.Screen; +} + +describe('ActivityStream', () => { + let activityStream: ActivityStream; + let mockScreen: blessed.Widgets.Screen; + let mockLogInstance: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockScreen = createMockScreen(); + + // Get the mock log instance from the mock + const blessedMock = blessed as unknown as { log: vi.Mock }; + mockLogInstance = blessedMock.log(); + + activityStream = new ActivityStream({ + parent: mockScreen, + top: 0, + right: 0, + width: '50%', + bottom: 0, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create a blessed log with correct options', () => { + const blessedMock = blessed as unknown as { log: vi.Mock }; + expect(blessedMock.log).toHaveBeenCalledWith( + expect.objectContaining({ + parent: mockScreen, + top: 0, + right: 0, + width: '50%', + bottom: 0, + label: ' Activity Stream ', + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + }) + ); + }); + + it('should bind key handlers on construction', () => { + // Key bindings should be registered + expect(mockLogInstance.key).toHaveBeenCalled(); + }); + + it('should use default maxLines of 500', () => { + const stream = new ActivityStream({ + parent: mockScreen, + top: 0, + right: 0, + width: '50%', + bottom: 0, + }); + expect(stream).toBeDefined(); + }); + + it('should accept custom maxLines option', () => { + const stream = new ActivityStream({ + parent: mockScreen, + top: 0, + right: 0, + width: '50%', + bottom: 0, + maxLines: 100, + }); + expect(stream).toBeDefined(); + }); + }); + + describe('addEvent', () => { + it('should add event to the log', () => { + const event = createMockEvent(); + activityStream.addEvent(event); + + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + + it('should format event with timestamp', () => { + const event = createMockEvent({ ts: 1709347200000 }); // Fixed timestamp + activityStream.addEvent(event); + + const loggedContent = mockLogInstance.log.mock.calls[0][0]; + // Should contain formatted time + expect(loggedContent).toBeDefined(); + }); + + it('should format event with worker ID (truncated)', () => { + const event = createMockEvent({ worker: 'w-verylongworkerid123456' }); + activityStream.addEvent(event); + + const loggedContent = mockLogInstance.log.mock.calls[0][0]; + // Worker ID should be truncated to 8 characters + expect(loggedContent).toContain('w-verylo'); + }); + + it('should format event with log level', () => { + const event = createMockEvent({ level: 'error' }); + activityStream.addEvent(event); + + const loggedContent = mockLogInstance.log.mock.calls[0][0]; + expect(loggedContent).toContain('ERROR'); + }); + + it('should format event with tool if present', () => { + const event = createMockEvent({ tool: 'Read' }); + activityStream.addEvent(event); + + const loggedContent = mockLogInstance.log.mock.calls[0][0]; + expect(loggedContent).toContain('[Read]'); + }); + + it('should format event with bead if present', () => { + const event = createMockEvent({ bead: 'bd-abc123' }); + activityStream.addEvent(event); + + const loggedContent = mockLogInstance.log.mock.calls[0][0]; + expect(loggedContent).toContain('bd-abc123'); + }); + + it('should not display event when paused', () => { + activityStream.togglePause(); + const event = createMockEvent(); + activityStream.addEvent(event); + + // Event is added to internal array but not displayed + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should not display event when filtered out', () => { + activityStream.setFilter({ workerId: 'w-specific' }); + + const event = createMockEvent({ worker: 'w-other' }); + activityStream.addEvent(event); + + // Event doesn't match filter + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should trim old events when exceeding maxLines', () => { + // Create a stream with small maxLines + const smallStream = new ActivityStream({ + parent: mockScreen, + top: 0, + right: 0, + width: '50%', + bottom: 0, + maxLines: 5, + }); + + // Add more events than maxLines + for (let i = 0; i < 10; i++) { + smallStream.addEvent(createMockEvent({ msg: `Event ${i}` })); + } + + // Should not throw and should have trimmed + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + }); + + describe('addEvents', () => { + it('should add multiple events', () => { + const events = [ + createMockEvent({ msg: 'First' }), + createMockEvent({ msg: 'Second' }), + createMockEvent({ msg: 'Third' }), + ]; + + activityStream.addEvents(events); + + expect(mockLogInstance.log).toHaveBeenCalledTimes(3); + }); + + it('should handle empty array', () => { + activityStream.addEvents([]); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + }); + + describe('togglePause', () => { + it('should toggle pause state', () => { + expect(activityStream.getIsPaused()).toBe(false); + + activityStream.togglePause(); + expect(activityStream.getIsPaused()).toBe(true); + + activityStream.togglePause(); + expect(activityStream.getIsPaused()).toBe(false); + }); + + it('should update label when paused', () => { + activityStream.togglePause(); + + expect(mockLogInstance.setLabel).toHaveBeenCalledWith(' Activity Stream [PAUSED] '); + }); + + it('should update label when unpaused', () => { + activityStream.togglePause(); // Pause + activityStream.togglePause(); // Unpause + + expect(mockLogInstance.setLabel).toHaveBeenCalledWith(' Activity Stream '); + }); + + it('should trigger screen render', () => { + activityStream.togglePause(); + + expect(mockLogInstance.screen.render).toHaveBeenCalled(); + }); + }); + + describe('setFilter', () => { + it('should set filter and re-render', () => { + const filter: ActivityFilter = { workerId: 'w-test' }; + activityStream.setFilter(filter); + + expect(mockLogInstance.setContent).toHaveBeenCalled(); + expect(mockLogInstance.screen.render).toHaveBeenCalled(); + }); + + it('should filter by workerId', () => { + activityStream.setFilter({ workerId: 'w-specific' }); + + // Add matching event + activityStream.addEvent(createMockEvent({ worker: 'w-specific' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Add non-matching event + activityStream.addEvent(createMockEvent({ worker: 'w-other' })); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should filter by level', () => { + activityStream.setFilter({ level: 'error' }); + + // Add matching event + activityStream.addEvent(createMockEvent({ level: 'error' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Add non-matching event + activityStream.addEvent(createMockEvent({ level: 'info' })); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should filter by search term in message', () => { + activityStream.setFilter({ search: 'important' }); + + // Add matching event + activityStream.addEvent(createMockEvent({ msg: 'This is important' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Add non-matching event + activityStream.addEvent(createMockEvent({ msg: 'Something else' })); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should filter by search term in worker ID', () => { + activityStream.setFilter({ search: 'alpha' }); + + // Add matching event + activityStream.addEvent(createMockEvent({ worker: 'w-alpha-worker' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Add non-matching event + activityStream.addEvent(createMockEvent({ worker: 'w-beta-worker' })); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should filter by search term in tool', () => { + activityStream.setFilter({ search: 'read' }); + + // Add matching event + activityStream.addEvent(createMockEvent({ tool: 'Read' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Add non-matching event + activityStream.addEvent(createMockEvent({ tool: 'Write' })); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should filter by search term in bead', () => { + activityStream.setFilter({ search: 'abc' }); + + // Add matching event + activityStream.addEvent(createMockEvent({ bead: 'bd-abc123' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Add non-matching event + activityStream.addEvent(createMockEvent({ bead: 'bd-xyz789' })); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + }); + + it('should be case-insensitive for search', () => { + activityStream.setFilter({ search: 'IMPORTANT' }); + + // Add matching event with different case + activityStream.addEvent(createMockEvent({ msg: 'This is important' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + + it('should combine multiple filter criteria (AND)', () => { + activityStream.setFilter({ workerId: 'w-test', level: 'error' }); + + // Add event matching only workerId + activityStream.addEvent(createMockEvent({ worker: 'w-test', level: 'info' })); + expect(mockLogInstance.log).not.toHaveBeenCalled(); + + // Add event matching both + activityStream.addEvent(createMockEvent({ worker: 'w-test', level: 'error' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + }); + + describe('clearFilter', () => { + it('should clear filter and re-render', () => { + activityStream.setFilter({ workerId: 'w-test' }); + vi.clearAllMocks(); + + activityStream.clearFilter(); + + expect(mockLogInstance.setContent).toHaveBeenCalled(); + expect(mockLogInstance.screen.render).toHaveBeenCalled(); + }); + + it('should allow all events after clear', () => { + activityStream.setFilter({ workerId: 'w-specific' }); + activityStream.clearFilter(); + + vi.clearAllMocks(); + + // Events from any worker should now be shown + activityStream.addEvent(createMockEvent({ worker: 'w-any' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should clear all events', () => { + activityStream.addEvent(createMockEvent()); + activityStream.clear(); + + expect(mockLogInstance.setContent).toHaveBeenCalledWith(''); + expect(mockLogInstance.screen.render).toHaveBeenCalled(); + }); + }); + + describe('focus', () => { + it('should focus the log element', () => { + activityStream.focus(); + expect(mockLogInstance.focus).toHaveBeenCalled(); + }); + }); + + describe('getElement', () => { + it('should return the log element', () => { + const element = activityStream.getElement(); + expect(element).toBe(mockLogInstance); + }); + }); + + describe('getIsPaused', () => { + it('should return false initially', () => { + expect(activityStream.getIsPaused()).toBe(false); + }); + + it('should return true after pause', () => { + activityStream.togglePause(); + expect(activityStream.getIsPaused()).toBe(true); + }); + }); + + describe('key bindings', () => { + it('should bind p key to togglePause', () => { + expect(mockLogInstance.key).toHaveBeenCalledWith(['p'], expect.any(Function)); + }); + + it('should bind C-c key to clear', () => { + expect(mockLogInstance.key).toHaveBeenCalledWith(['C-c'], expect.any(Function)); + }); + + it('should toggle pause when p is pressed', () => { + // Find the 'p' handler and call it + const pCall = mockLogInstance.key.mock.calls.find( + (call: unknown[]) => Array.isArray(call?.[0]) && call[0].includes('p') + ); + const pHandler = pCall?.[1]; + if (pHandler) { + pHandler(); + } + + expect(mockLogInstance.setLabel).toHaveBeenCalledWith(' Activity Stream [PAUSED] '); + }); + + it('should clear when C-c is pressed', () => { + // Find the 'C-c' handler and call it + const ccCall = mockLogInstance.key.mock.calls.find( + (call: unknown[]) => Array.isArray(call?.[0]) && call[0].includes('C-c') + ); + const ccHandler = ccCall?.[1]; + if (ccHandler) { + ccHandler(); + } + + expect(mockLogInstance.setContent).toHaveBeenCalledWith(''); + }); + }); + + describe('reRender behavior', () => { + it('should re-render only matching events when filter changes', () => { + // Add some events + activityStream.addEvent(createMockEvent({ worker: 'w-alpha', msg: 'Alpha event' })); + activityStream.addEvent(createMockEvent({ worker: 'w-beta', msg: 'Beta event' })); + activityStream.addEvent(createMockEvent({ worker: 'w-alpha', msg: 'Another alpha' })); + + vi.clearAllMocks(); + + // Set filter to only show alpha events + activityStream.setFilter({ workerId: 'w-alpha' }); + + // setContent should have been called (clearing and re-adding) + expect(mockLogInstance.setContent).toHaveBeenCalledWith(''); + // log should be called for matching events (last 100) + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + + it('should limit re-render to last 100 matching events', () => { + // Add many events + for (let i = 0; i < 150; i++) { + activityStream.addEvent(createMockEvent({ msg: `Event ${i}` })); + } + + vi.clearAllMocks(); + + // Trigger re-render + activityStream.setFilter({}); + + // log should be called (but limited to 100) + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should handle event with empty message', () => { + const event = createMockEvent({ msg: '' }); + expect(() => activityStream.addEvent(event)).not.toThrow(); + }); + + it('should handle event with all optional fields missing', () => { + const event: LogEvent = { + ts: Date.now(), + worker: 'w-test', + level: 'info', + msg: 'Basic event', + }; + expect(() => activityStream.addEvent(event)).not.toThrow(); + }); + + it('should handle event with all optional fields present', () => { + const event: LogEvent = { + ts: Date.now(), + worker: 'w-test', + level: 'debug', + msg: 'Full event', + tool: 'Read', + path: '/some/path', + bead: 'bd-123', + duration_ms: 100, + error: undefined, + }; + expect(() => activityStream.addEvent(event)).not.toThrow(); + }); + + it('should handle filter with empty search string', () => { + activityStream.setFilter({ search: '' }); + + // Empty search should match all events + activityStream.addEvent(createMockEvent()); + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + + it('should handle very long message', () => { + const longMessage = 'A'.repeat(1000); + const event = createMockEvent({ msg: longMessage }); + expect(() => activityStream.addEvent(event)).not.toThrow(); + }); + + it('should handle special characters in search', () => { + activityStream.setFilter({ search: '[test]' }); + + // Should handle regex-like characters as literal + activityStream.addEvent(createMockEvent({ msg: 'This has [test] in it' })); + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + + it('should handle unicode in messages', () => { + const event = createMockEvent({ msg: 'Unicode: \u4e2d\u6587 \ud83d\ude00' }); + expect(() => activityStream.addEvent(event)).not.toThrow(); + }); + + it('should handle all log levels', () => { + const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['debug', 'info', 'warn', 'error']; + + for (const level of levels) { + const event = createMockEvent({ level }); + expect(() => activityStream.addEvent(event)).not.toThrow(); + } + }); + + it('should handle events added while paused', () => { + activityStream.togglePause(); + + // Add events while paused + activityStream.addEvent(createMockEvent({ msg: 'Event 1' })); + activityStream.addEvent(createMockEvent({ msg: 'Event 2' })); + + // Unpause + activityStream.togglePause(); + + // Events should be in internal storage (shown on re-render) + activityStream.setFilter({}); + expect(mockLogInstance.log).toHaveBeenCalled(); + }); + }); + + describe('position options', () => { + it('should accept string positions', () => { + const stream = new ActivityStream({ + parent: mockScreen, + top: '10%', + right: '5%', + width: '40%', + bottom: '20%', + }); + + const blessedMock = blessed as unknown as { log: vi.Mock }; + expect(blessedMock.log).toHaveBeenCalledWith( + expect.objectContaining({ + top: '10%', + right: '5%', + width: '40%', + bottom: '20%', + }) + ); + }); + + it('should accept numeric positions', () => { + const stream = new ActivityStream({ + parent: mockScreen, + top: 5, + right: 0, + width: 50, + bottom: 10, + }); + + const blessedMock = blessed as unknown as { log: vi.Mock }; + expect(blessedMock.log).toHaveBeenCalledWith( + expect.objectContaining({ + top: 5, + right: 0, + width: 50, + bottom: 10, + }) + ); + }); + }); +}); diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 5af87a5..fc0e3ac 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -1,11 +1,12 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { LogEvent, WorkerInfo, WebSocketMessage, CollisionAlert as CollisionAlertData } from './types'; +import { LogEvent, WorkerInfo, WebSocketMessage, CollisionAlert as CollisionAlertData, RecoverySuggestion } from './types'; import WorkerGrid from './components/WorkerGrid'; import ActivityStream from './components/ActivityStream'; import WorkerDetail from './components/WorkerDetail'; import CollisionAlert from './components/CollisionAlert'; import FileHeatmap from './components/FileHeatmap'; import DependencyDag from './components/DependencyDag'; +import RecoveryPanel from './components/RecoveryPanel'; const App: React.FC = () => { const [workers, setWorkers] = useState([]); @@ -16,6 +17,8 @@ const App: React.FC = () => { const [showCollisionPanel, setShowCollisionPanel] = useState(false); const [showFileHeatmap, setShowFileHeatmap] = useState(false); const [showDependencyDag, setShowDependencyDag] = useState(false); + const [showRecoveryPanel, setShowRecoveryPanel] = useState(false); + const [recoverySuggestions, setRecoverySuggestions] = useState([]); const handleWebSocketMessage = useCallback((message: WebSocketMessage) => { if (message.type === 'init') { diff --git a/src/web/frontend/src/components/RecoveryPanel.tsx b/src/web/frontend/src/components/RecoveryPanel.tsx new file mode 100644 index 0000000..7416c60 --- /dev/null +++ b/src/web/frontend/src/components/RecoveryPanel.tsx @@ -0,0 +1,428 @@ +import React, { useState, useMemo } from 'react'; +import { + RecoverySuggestion, + RecoveryAction, + RecoveryPriority, + RecoveryActionType, + ErrorCategory, +} from '../types'; + +interface RecoveryPanelProps { + /** Array of recovery suggestions to display */ + suggestions: RecoverySuggestion[]; + + /** Callback when an action is executed */ + onExecuteAction?: (suggestionId: string, actionId: string) => void; + + /** Callback when a suggestion is dismissed */ + onDismissSuggestion?: (suggestionId: string) => void; + + /** Whether the panel is visible */ + visible?: boolean; + + /** Callback to close the panel */ + onClose?: () => void; + + /** Show only active suggestions */ + activeOnly?: boolean; + + /** Show only automated actions */ + automatedOnly?: boolean; + + /** Maximum suggestions to show */ + maxSuggestions?: number; +} + +// Priority colors +const PRIORITY_COLORS: Record = { + immediate: 'var(--error)', + high: 'var(--warning)', + normal: 'var(--info)', + low: 'var(--text-secondary)', +}; + +// Priority badges +const PRIORITY_BADGES: Record = { + immediate: '!!!', + high: '!!', + normal: '!', + low: '.', +}; + +// Action type icons +const ACTION_TYPE_ICONS: Record = { + retry: '🔄', + backoff: '⏳', + alternative: '🔀', + escalate: '👤', + skip: '⏭️', + fix_config: '⚙️', + install_dep: '📦', + fix_permissions: '🔐', + cleanup: '🧹', + restart: '🔁', + investigate: '🔍', +}; + +// Category icons +const CATEGORY_ICONS: Record = { + network: '🌐', + permission: '🔐', + validation: '✓', + resource: '💾', + not_found: '❓', + timeout: '⏱️', + syntax: '📝', + tool: '🔧', + unknown: '❗', +}; + +// Category labels +const CATEGORY_LABELS: Record = { + network: 'Network Error', + permission: 'Permission Denied', + validation: 'Validation Error', + resource: 'Resource Limit', + not_found: 'Not Found', + timeout: 'Timeout', + syntax: 'Syntax Error', + tool: 'Tool Error', + unknown: 'Unknown Error', +}; + +/** + * Format confidence as percentage + */ +function formatConfidence(confidence: number): string { + return `${Math.round(confidence * 100)}%`; +} + +/** + * Format estimated time + */ +function formatEstimatedTime(seconds?: number): string { + if (!seconds) return ''; + if (seconds < 60) return `~${seconds}s`; + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `~${minutes}m ${secs}s` : `~${minutes}m`; +} + +/** + * Truncate string with ellipsis + */ +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return str.slice(0, maxLength - 3) + '...'; +} + +/** + * RecoveryPanel Component + * + * Displays recovery suggestions when workers encounter errors. + * Shows actionable steps based on error patterns. + * Ported from TUI RecoveryPanel.ts + */ +const RecoveryPanel: React.FC = ({ + suggestions, + onExecuteAction, + onDismissSuggestion, + visible = true, + onClose, + activeOnly = true, + automatedOnly = false, + maxSuggestions = 10, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const [expandedIndex, setExpandedIndex] = useState(null); + + // Filter suggestions + const filteredSuggestions = useMemo(() => { + let filtered = suggestions; + + if (activeOnly) { + filtered = filtered.filter((s) => s.isActive); + } + + if (automatedOnly) { + filtered = filtered.filter((s) => s.actions.some((a) => a.automated)); + } + + return filtered.slice(0, maxSuggestions); + }, [suggestions, activeOnly, automatedOnly, maxSuggestions]); + + // Stats + const stats = useMemo(() => { + const active = filteredSuggestions.filter((s) => s.isActive).length; + const automated = filteredSuggestions.filter((s) => + s.actions.some((a) => a.automated) + ).length; + + return { + total: filteredSuggestions.length, + active, + automated, + }; + }, [filteredSuggestions]); + + const handleSelectSuggestion = (index: number) => { + setSelectedIndex(index); + }; + + const handleToggleExpand = (index: number) => { + setExpandedIndex(expandedIndex === index ? null : index); + }; + + const handleExecuteAction = (suggestionId: string, action: RecoveryAction) => { + onExecuteAction?.(suggestionId, action.id); + }; + + const handleDismiss = (suggestionId: string) => { + onDismissSuggestion?.(suggestionId); + }; + + const getPriorityClass = (priority: RecoveryPriority): string => { + return `recovery-priority-${priority}`; + }; + + const getAutomatedClass = (automated: boolean): string => { + return automated ? 'recovery-action-automated' : 'recovery-action-manual'; + }; + + if (!visible) { + return null; + } + + const selectedSuggestion = filteredSuggestions[selectedIndex]; + + return ( +
+ {/* Header */} +
+

+ 💊 + Recovery Playbook + {stats.active > 0 && ( + {stats.active} + )} +

+ {onClose && ( + + )} +
+ + {/* Content */} +
+ {filteredSuggestions.length === 0 ? ( +
+ + No recovery suggestions available + + Errors will appear here when workers encounter issues. + +
+ ) : ( + <> + {/* Summary */} +
+ + Suggestions: {stats.total} ({stats.active} active, {stats.automated} automated) + +
+ + {/* Suggestions List */} +
+ {filteredSuggestions.map((suggestion, index) => { + const isExpanded = expandedIndex === index; + const isSelected = selectedIndex === index; + const icon = CATEGORY_ICONS[suggestion.category]; + const confidence = formatConfidence(suggestion.confidence); + const workersCount = suggestion.affectedWorkers.length; + + return ( +
handleSelectSuggestion(index)} + > + {/* Suggestion Header */} +
+ { + e.stopPropagation(); + handleToggleExpand(index); + }} + > + {isExpanded ? '▼' : '▶'} + + {icon} + + {truncate(suggestion.title, 40)} + + + {suggestion.isActive ? 'ACTIVE' : 'RESOLVED'} + +
+ + {/* Suggestion Meta */} +
+ + {truncate(suggestion.errorSummary, 60)} + + + Confidence: {confidence} + + + Workers: {workersCount} + +
+ + {/* Expanded Actions */} + {isExpanded && ( +
+
+ Recovery Actions: +
+ {suggestion.actions.slice(0, 5).map((action) => { + const actionIcon = ACTION_TYPE_ICONS[action.type]; + const priorityBadge = PRIORITY_BADGES[action.priority]; + + return ( +
+
+ + [{priorityBadge}] + + {actionIcon} + + [{action.automated ? 'AUTO' : 'MANUAL'}] + + {action.title} +
+ + {action.description && ( +
+ {truncate(action.description, 70)} +
+ )} + + {action.command && ( +
+ $ {truncate(action.command, 60)} +
+ )} + + {action.estimatedTime && ( +
+ Est. time: {formatEstimatedTime(action.estimatedTime)} +
+ )} + + {action.automated && onExecuteAction && ( + + )} +
+ ); + })} + + {/* Dismiss Button */} + {onDismissSuggestion && ( +
+ +
+ )} +
+ )} +
+ ); + })} +
+ + {/* Selected Detail */} + {selectedSuggestion && expandedIndex === null && ( +
+
+ ------------------------------------------ +
+
+ Selected: {CATEGORY_ICONS[selectedSuggestion.category]}{' '} + {CATEGORY_LABELS[selectedSuggestion.category]} +
+
+ Title: + {selectedSuggestion.title} +
+
+ + {selectedSuggestion.errorSummary} + +
+
+ Confidence: + + {formatConfidence(selectedSuggestion.confidence)} + +
+
+ Workers: + + {selectedSuggestion.affectedWorkers.join(', ')} + +
+ {selectedSuggestion.actions.length > 0 && ( +
+ Top action: {ACTION_TYPE_ICONS[selectedSuggestion.actions[0].type]}{' '} + {selectedSuggestion.actions[0].title} +
+ )} +
+ +
+
+ )} + + )} +
+ + {/* Footer */} +
+ ↑↓ Navigate | Enter Expand | Esc Collapse +
+
+ ); +}; + +export default RecoveryPanel; diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index 5903b60..345f2a2 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -1901,3 +1901,441 @@ body { flex: 1 1 45%; } } + +/* ============================================ + Recovery Panel Component Styles + ============================================ */ + +.recovery-panel { + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border: 1px solid var(--bg-tertiary); + border-radius: 6px; + overflow: hidden; + min-width: 350px; + max-width: 450px; + max-height: 500px; +} + +.recovery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--bg-primary); +} + +.recovery-header h2 { + font-size: 0.875rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--text-primary); +} + +.recovery-header-icon { + font-size: 1rem; +} + +.recovery-badge { + background: var(--error); + color: #fff; + font-size: 0.7rem; + padding: 0.125rem 0.375rem; + border-radius: 10px; + margin-left: 0.25rem; +} + +.recovery-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + transition: all 0.2s; +} + +.recovery-close:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.recovery-content { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.recovery-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--success); + gap: 0.5rem; +} + +.recovery-empty-icon { + font-size: 1.5rem; + font-weight: bold; +} + +.recovery-empty-hint { + font-size: 0.75rem; + color: var(--text-secondary); + text-align: center; + margin-top: 0.5rem; +} + +.recovery-summary { + padding: 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid var(--bg-tertiary); + margin-bottom: 0.5rem; +} + +.recovery-count { + font-weight: 500; +} + +.recovery-suggestions-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.recovery-suggestion { + background: var(--bg-tertiary); + border-radius: 4px; + padding: 0.5rem; + cursor: pointer; + transition: all 0.2s; + border: 2px solid transparent; +} + +.recovery-suggestion:hover { + background: var(--bg-primary); +} + +.recovery-suggestion.selected { + border-color: var(--accent); +} + +.recovery-suggestion.resolved { + opacity: 0.6; +} + +.recovery-suggestion-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.recovery-expand-icon { + color: var(--text-secondary); + cursor: pointer; + font-size: 0.75rem; + min-width: 16px; +} + +.recovery-expand-icon:hover { + color: var(--accent); +} + +.recovery-category-icon { + font-size: 0.875rem; +} + +.recovery-suggestion-title { + flex: 1; + font-weight: 500; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recovery-status-badge { + font-size: 0.7rem; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-weight: 600; + text-transform: uppercase; +} + +.recovery-status-badge.active { + background: var(--success); + color: #fff; +} + +.recovery-status-badge:not(.active) { + background: var(--text-secondary); + color: #000; +} + +.recovery-suggestion-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary); + padding-left: 1.5rem; +} + +.recovery-error-summary { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recovery-confidence { + color: var(--info); +} + +.recovery-workers-count { + color: #00bcd4; +} + +/* Recovery Actions */ +.recovery-actions { + margin-top: 0.5rem; + padding: 0.5rem; + background: var(--bg-primary); + border-radius: 4px; + border-top: 1px solid var(--bg-tertiary); +} + +.recovery-actions-header { + font-weight: 600; + font-size: 0.8125rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.recovery-action { + padding: 0.375rem 0; + border-bottom: 1px solid var(--bg-tertiary); +} + +.recovery-action:last-of-type { + border-bottom: none; +} + +.recovery-action-header { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; +} + +.recovery-priority-badge { + font-weight: bold; + font-size: 0.75rem; +} + +.recovery-action-icon { + font-size: 0.875rem; +} + +.recovery-action-automated { + color: var(--success); + font-size: 0.7rem; +} + +.recovery-action-manual { + color: var(--warning); + font-size: 0.7rem; +} + +.recovery-action-title { + flex: 1; + color: var(--text-primary); +} + +.recovery-action-description { + font-size: 0.75rem; + color: var(--text-secondary); + padding: 0.25rem 0 0.25rem 1rem; +} + +.recovery-action-command { + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.75rem; + color: #00bcd4; + padding: 0.125rem 0 0.125rem 1rem; +} + +.recovery-action-time { + font-size: 0.75rem; + color: var(--info); + padding: 0.125rem 0 0.125rem 1rem; +} + +.recovery-execute-btn { + background: var(--bg-tertiary); + border: none; + color: var(--accent); + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-size: 0.7rem; + cursor: pointer; + margin-top: 0.25rem; + margin-left: 1rem; + transition: all 0.2s; +} + +.recovery-execute-btn:hover { + background: var(--accent); + color: #fff; +} + +.recovery-action-footer { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--bg-tertiary); +} + +.recovery-dismiss-btn { + background: none; + border: 1px solid var(--text-secondary); + color: var(--text-secondary); + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-size: 0.7rem; + cursor: pointer; + transition: all 0.2s; +} + +.recovery-dismiss-btn:hover { + background: var(--bg-tertiary); + color: var(--error); + border-color: var(--error); +} + +/* Recovery Detail Panel */ +.recovery-detail { + margin-top: 0.5rem; + padding: 0.5rem; + border-top: 1px solid var(--bg-tertiary); +} + +.recovery-detail-divider { + color: var(--text-secondary); + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.75rem; + margin-bottom: 0.5rem; +} + +.recovery-detail-header { + font-weight: 600; + font-size: 0.8125rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.recovery-detail-row { + font-size: 0.8125rem; + padding: 0.25rem 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.recovery-detail-label { + color: var(--text-secondary); + font-size: 0.75rem; +} + +.recovery-detail-value { + color: var(--text-primary); +} + +.recovery-detail-suggestion { + color: #00bcd4; + font-size: 0.8125rem; + padding: 0.375rem 0; + font-style: italic; +} + +.recovery-detail-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--bg-tertiary); +} + +.recovery-action-btn { + background: var(--bg-tertiary); + border: none; + color: var(--text-secondary); + padding: 0.375rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.recovery-action-btn:hover { + background: var(--bg-primary); + color: var(--accent); +} + +/* Recovery Footer */ +.recovery-footer { + display: flex; + justify-content: center; + padding: 0.5rem 1rem; + background: var(--bg-primary); + border-top: 1px solid var(--bg-tertiary); +} + +.recovery-help { + font-size: 0.7rem; + color: var(--text-secondary); +} + +/* Priority color classes */ +.recovery-priority-immediate { + color: var(--error); +} + +.recovery-priority-high { + color: var(--warning); +} + +.recovery-priority-normal { + color: var(--info); +} + +.recovery-priority-low { + color: var(--text-secondary); +} + +/* Responsive adjustments for Recovery Panel */ +@media (max-width: 768px) { + .recovery-panel { + max-width: 100%; + max-height: 80vh; + } + + .recovery-suggestion-meta { + flex-direction: column; + gap: 0.25rem; + } + + .recovery-detail-actions { + flex-direction: column; + } +}