From 9d67217b753e67f62f8a4a2634adb8b8ef8d3530 Mon Sep 17 00:00:00 2001 From: jeda Date: Wed, 4 Mar 2026 03:34:09 +0000 Subject: [PATCH] feat(bd-qt4): Implement Focus Mode state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add focus mode to filter display to pinned workers/tasks: - Added focus mode toggle button in app header - Implemented pin/unpin functionality for workers - Implemented pin/unpin functionality for beads - Added hide-all-except-pinned filtering logic - State persisted to localStorage - Added comprehensive Focus Mode UI with CSS styling - Created 17 new tests for Focus Mode (all passing) Changes: - App.tsx: Added Focus Mode state (enabled, pinnedWorkers, pinnedBeads) - App.tsx: Added filtering logic for workers and events based on pinned state - WorkerGrid.tsx: Added pin button UI for workers - ActivityStream.tsx: Added bead pin buttons and event filtering - types.ts: Added bead field to LogEvent interface - index.css: Added Focus Mode CSS styles - FocusMode.test.tsx: Added comprehensive test coverage All 633 tests passing. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .beads/issues.jsonl | 2 +- src/web/frontend/src/App.tsx | 107 ++++- .../src/components/ActivityStream.tsx | 96 ++++- .../frontend/src/components/WorkerGrid.tsx | 110 ++++-- src/web/frontend/src/index.css | 188 +++++++++ src/web/frontend/src/types.ts | 1 + src/web/frontend/test/FocusMode.test.tsx | 367 ++++++++++++++++++ 7 files changed, 814 insertions(+), 57 deletions(-) create mode 100644 src/web/frontend/test/FocusMode.test.tsx diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3f9faef..981af35 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -132,7 +132,7 @@ {"id":"bd-n8l","title":"Phase 2: TUI Display","description":"# Phase 2: TUI Display\n\n## Overview\nBuild the terminal user interface for FABRIC. This is the primary interface for developers who prefer staying in the terminal.\n\n## Goals\n1. **Worker Grid**: Real-time status of all active workers\n2. **Log Stream**: Scrolling log output as events arrive\n3. **Detail Panel**: Focus on a specific worker's activity\n4. **Keyboard Navigation**: j/k scroll, / search, Tab switch panels, q quit\n5. **Command Palette**: Ctrl+K for universal search and commands\n6. **File Context**: Split view showing file contents alongside activity\n7. **Focus Mode**: Pin workers/tasks to filter noise\n\n## Key Design Decisions\n- Use `blessed` or `ink` for terminal UI (ink preferred for React patterns)\n- All panels should update independently (no full-screen refresh)\n- Keyboard shortcuts should be discoverable (help overlay)\n- Support 256-color and true-color terminals\n\n## Layout\n```\nā”Œā”€ FABRIC ─────────────────────────────────────────────────┐\n│ │\n│ Workers (N active) [?] Help │\n│ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │\n│ │ ā— w-alpha Running bd-1847 \"Implement...\" 2m │ │\n│ │ ā— w-bravo Running bd-1852 \"Fix...\" 1m │ │\n│ │ ā—‹ w-charlie Idle - - - │ │\n│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │\n│ │\n│ Activity Stream Filter: [All ā–¾] │\n│ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │\n│ │ 14:32:07 w-alpha INFO Tool call: Edit... │ │\n│ │ 14:32:05 w-bravo DEBUG Reading file: ... │ │\n│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │\n│ │\n│ [Tab] Switch [j/k] Scroll [/] Search [q] Quit │\nā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n```\n\n## Dependencies\n- Phase 1: Core Infrastructure (event emitter, event store)\n\n## Success Criteria\n- UI renders correctly in terminals 80x24 to 200x60\n- All keyboard interactions complete in <50ms\n- Smooth scrolling at 100+ events/second\n- Works over SSH connections\n\n## Child Beads\n- bd-P2-001: TUI Framework Setup\n- bd-P2-010: Worker List Panel\n- bd-P2-020: Live Log Stream Panel\n- bd-P2-030: Worker Detail Panel\n- bd-P2-040: Keyboard Controls\n- bd-P2-050: Command Palette (TUI)\n- bd-P2-060: File Context Panel\n- bd-P2-070: Focus Mode (TUI)","status":"closed","priority":0,"issue_type":"phase","created_at":"2026-03-02T14:38:59.011210511Z","created_by":"coder","updated_at":"2026-03-03T10:36:46.832672612Z","closed_at":"2026-03-03T10:36:46.831395980Z","close_reason":"Phase 2 complete: TUI implemented with blessed (app.ts, WorkerGrid, ActivityStream, WorkerDetail, CommandPalette, DiffView)","source_repo":".","compaction_level":0,"original_size":0} {"id":"bd-noj","title":"Tests pass for React Testing Library tests for ActivityStream component","description":"Add unit tests for src/web/frontend/src/components/ActivityStream.tsx using React Testing Library and Vitest","status":"closed","priority":2,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T14:27:33.093087379Z","created_by":"coder","updated_at":"2026-03-03T14:50:00.487545467Z","closed_at":"2026-03-03T14:50:00.454409760Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"labels":["frontend","phase-3","testing","web"]} {"id":"bd-ph8","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** 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:** 24154s (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","assignee":"coder","created_at":"2026-03-03T11:06:06.867109233Z","created_by":"coder","updated_at":"2026-03-03T11:09:26.414713988Z","closed_at":"2026-03-03T11:09:26.411733198Z","close_reason":"FALSE POSITIVE: Ready queue has 22 available beads. Worker discovery failed to check .beads/ready-queue.json before escalating. Known pattern - see MEMORY.md for similar closures (bd-1k7, bd-zsh, bd-9r6, etc.)","source_repo":".","compaction_level":0,"original_size":0} -{"id":"bd-qt4","title":"Implement Focus Mode state management","description":"Add focus mode to filter display to pinned workers/tasks. Implement: pin/unpin workers, pin/unpin beads, hide-all-except-pinned toggle. Store in app state.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-04T03:06:12.485197889Z","created_by":"coder","updated_at":"2026-03-04T03:06:12.485197889Z","source_repo":".","compaction_level":0,"original_size":0} +{"id":"bd-qt4","title":"Implement Focus Mode state management","description":"Add focus mode to filter display to pinned workers/tasks. Implement: pin/unpin workers, pin/unpin beads, hide-all-except-pinned toggle. Store in app state.","status":"in_progress","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-04T03:06:12.485197889Z","created_by":"coder","updated_at":"2026-03-04T03:28:58.622788807Z","source_repo":".","compaction_level":0,"original_size":0} {"id":"bd-r5c","title":"P4-002: Implement Worker Collision Detection","description":"Phase 4 Intelligence: Detect when multiple workers modify the same file concurrently. Alert in UI with visual indicator. Track collision events in store.","status":"closed","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T07:53:39.797693351Z","created_by":"coder","updated_at":"2026-03-03T10:45:43.866171866Z","closed_at":"2026-03-03T10:45:43.864830896Z","close_reason":"Collision detection implemented in store.ts with getCollisions(), getWorkerCollisions(), hasCollision tracking, and 9 collision tests in store.test.ts","source_repo":".","compaction_level":0,"original_size":0,"labels":["collision","intelligence","phase-4"]} {"id":"bd-tq6","title":"P4-005: Task Dependency DAG","description":"Implement task dependency DAG visualization - show the directed acyclic graph of task dependencies. Help users understand which tasks block others.","status":"closed","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T13:30:43.364282177Z","created_by":"coder","updated_at":"2026-03-03T14:17:28.686776211Z","closed_at":"2026-03-03T14:17:28.671336071Z","close_reason":"completed","source_repo":".","compaction_level":0,"original_size":0,"labels":["dag","dependencies","intelligence","phase-4"]} {"id":"bd-wjq","title":"P4-003: Task Dependency DAG","description":"Implement task dependency visualization - show the directed acyclic graph of bead dependencies. Workers can see which tasks block others and critical path.","status":"closed","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T11:42:57.836850370Z","created_by":"coder","updated_at":"2026-03-03T12:15:28.743588623Z","closed_at":"2026-03-03T12:15:28.736715882Z","close_reason":"completed","source_repo":".","compaction_level":0,"original_size":0,"labels":["dag","intelligence","phase-4","visualization"]} diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 163c22b..f62dabf 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -8,6 +8,14 @@ import FileHeatmap from './components/FileHeatmap'; import DependencyDag from './components/DependencyDag'; import RecoveryPanel from './components/RecoveryPanel'; +const FOCUS_MODE_STORAGE_KEY = 'fabric-focus-mode'; + +interface FocusModeState { + enabled: boolean; + pinnedWorkers: string[]; + pinnedBeads: string[]; +} + const App: React.FC = () => { const [workers, setWorkers] = useState([]); const [events, setEvents] = useState([]); @@ -20,6 +28,36 @@ const App: React.FC = () => { const [showRecoveryPanel, setShowRecoveryPanel] = useState(false); const [recoverySuggestions, setRecoverySuggestions] = useState([]); + // Focus Mode state + const [focusModeEnabled, setFocusModeEnabled] = useState(false); + const [pinnedWorkers, setPinnedWorkers] = useState>(new Set()); + const [pinnedBeads, setPinnedBeads] = useState>(new Set()); + + // Load Focus Mode state from localStorage on mount + useEffect(() => { + const savedState = localStorage.getItem(FOCUS_MODE_STORAGE_KEY); + if (savedState) { + try { + const parsed: FocusModeState = JSON.parse(savedState); + setFocusModeEnabled(parsed.enabled); + setPinnedWorkers(new Set(parsed.pinnedWorkers)); + setPinnedBeads(new Set(parsed.pinnedBeads)); + } catch (error) { + console.error('Failed to parse Focus Mode state:', error); + } + } + }, []); + + // Save Focus Mode state to localStorage whenever it changes + useEffect(() => { + const state: FocusModeState = { + enabled: focusModeEnabled, + pinnedWorkers: Array.from(pinnedWorkers), + pinnedBeads: Array.from(pinnedBeads), + }; + localStorage.setItem(FOCUS_MODE_STORAGE_KEY, JSON.stringify(state)); + }, [focusModeEnabled, pinnedWorkers, pinnedBeads]); + const handleWebSocketMessage = useCallback((message: WebSocketMessage) => { if (message.type === 'init') { const data = message.data as { workers?: WorkerInfo[]; recentEvents?: LogEvent[]; alerts?: CollisionAlertData[] }; @@ -97,11 +135,11 @@ const App: React.FC = () => { }, [handleWebSocketMessage]); const filteredEvents = selectedWorker - ? events.filter(e => e.worker === selectedWorker) - : events; + ? filteredEventsByFocusMode.filter(e => e.worker === selectedWorker) + : filteredEventsByFocusMode; const selectedWorkerInfo = selectedWorker - ? workers.find(w => w.id === selectedWorker) + ? filteredWorkers.find(w => w.id === selectedWorker) : null; const handleAcknowledgeAlert = useCallback((alertId: string) => { @@ -118,11 +156,66 @@ const App: React.FC = () => { const unacknowledgedAlertCount = collisionAlerts.filter(a => !a.acknowledged).length; + // Focus Mode callbacks + const toggleFocusMode = useCallback(() => { + setFocusModeEnabled(prev => !prev); + }, []); + + const togglePinWorker = useCallback((workerId: string) => { + setPinnedWorkers(prev => { + const newSet = new Set(prev); + if (newSet.has(workerId)) { + newSet.delete(workerId); + } else { + newSet.add(workerId); + } + return newSet; + }); + }, []); + + const togglePinBead = useCallback((beadId: string) => { + setPinnedBeads(prev => { + const newSet = new Set(prev); + if (newSet.has(beadId)) { + newSet.delete(beadId); + } else { + newSet.add(beadId); + } + return newSet; + }); + }, []); + + // Filter workers and events based on Focus Mode + const filteredWorkers = focusModeEnabled && pinnedWorkers.size > 0 + ? workers.filter(w => pinnedWorkers.has(w.id)) + : workers; + + const filteredEventsByFocusMode = focusModeEnabled && (pinnedWorkers.size > 0 || pinnedBeads.size > 0) + ? events.filter(e => { + const matchesPinnedWorker = pinnedWorkers.size === 0 || pinnedWorkers.has(e.worker); + const matchesPinnedBead = pinnedBeads.size === 0 || (e.bead && pinnedBeads.has(e.bead)); + return matchesPinnedWorker || matchesPinnedBead; + }) + : events; + return (

FABRIC

+ + ); + })} + {uniqueBeads.length > 5 && ( + + +{uniqueBeads.length - 5} more + + )} +
+ )} +
{filteredEvents.length === 0 ? ( @@ -105,18 +154,29 @@ const ActivityStream: React.FC = ({ : 'No events match the current filters'}
) : ( - filteredEvents.map((event, i) => ( -
- {formatTime(event.timestamp)} - {event.level} - {!selectedWorker && ( - [{truncateWorker(event.worker)}] - )} - - {event.tool ? `[${event.tool}] ` : ''}{event.message} - -
- )) + filteredEvents.map((event, i) => { + const eventBeadPinned = event.bead && pinnedBeads.has(event.bead); + return ( +
+ {formatTime(event.timestamp)} + {event.level} + {!selectedWorker && ( + [{truncateWorker(event.worker)}] + )} + {event.bead && ( + + [{event.bead}] + + )} + + {event.tool ? `[${event.tool}] ` : ''}{event.message} + +
+ ); + }) )} diff --git a/src/web/frontend/src/components/WorkerGrid.tsx b/src/web/frontend/src/components/WorkerGrid.tsx index 3bc6357..3f8cc8e 100644 --- a/src/web/frontend/src/components/WorkerGrid.tsx +++ b/src/web/frontend/src/components/WorkerGrid.tsx @@ -5,9 +5,19 @@ interface WorkerGridProps { workers: WorkerInfo[]; selectedWorker: string | null; onSelectWorker: (id: string | null) => void; + pinnedWorkers?: Set; + onTogglePin?: (workerId: string) => void; + focusModeEnabled?: boolean; } -const WorkerGrid: React.FC = ({ workers, selectedWorker, onSelectWorker }) => { +const WorkerGrid: React.FC = ({ + workers, + selectedWorker, + onSelectWorker, + pinnedWorkers = new Set(), + onTogglePin, + focusModeEnabled = false, +}) => { const formatLastSeen = (timestamp: string) => { const diff = Date.now() - new Date(timestamp).getTime(); const seconds = Math.floor(diff / 1000); @@ -18,50 +28,82 @@ const WorkerGrid: React.FC = ({ workers, selectedWorker, onSele return `${hours}h ago`; }; + const handlePinClick = (e: React.MouseEvent, workerId: string) => { + e.stopPropagation(); // Prevent card selection when clicking pin + if (onTogglePin) { + onTogglePin(workerId); + } + }; + return (
-

Workers ({workers.length})

+

+ Workers ({workers.length}) + {focusModeEnabled && pinnedWorkers.size > 0 && ( + + (Focus: {pinnedWorkers.size} pinned) + + )} +

{workers.length === 0 ? (
-

No workers detected

+

{focusModeEnabled && pinnedWorkers.size === 0 + ? 'No pinned workers. Pin workers to see them in Focus Mode.' + : 'No workers detected'}

- Waiting for log events... + {focusModeEnabled && pinnedWorkers.size === 0 + ? 'Disable Focus Mode to see all workers' + : 'Waiting for log events...'}

) : ( - workers.map(worker => ( -
onSelectWorker(selectedWorker === worker.id ? null : worker.id)} - > -
- - {worker.id} - {worker.hasCollision && ( - - āš ļø - - )} - - - {worker.status} - -
-
- {worker.eventCount} events - {formatLastSeen(worker.lastSeen)} -
- {worker.hasCollision && worker.activeFiles && worker.activeFiles.length > 0 && ( -
- - Colliding on: {worker.activeFiles.length} file(s) + workers.map(worker => { + const isPinned = pinnedWorkers.has(worker.id); + return ( +
onSelectWorker(selectedWorker === worker.id ? null : worker.id)} + > +
+ + {worker.id} + {worker.hasCollision && ( + + āš ļø + + )} +
+ {onTogglePin && ( + + )} + + {worker.status} + +
- )} -
- )) +
+ {worker.eventCount} events + {formatLastSeen(worker.lastSeen)} +
+ {worker.hasCollision && worker.activeFiles && worker.activeFiles.length > 0 && ( +
+ + Colliding on: {worker.activeFiles.length} file(s) + +
+ )} +
+ ); + }) )}
); diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index c07767c..c80cd0c 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -2472,3 +2472,191 @@ body { flex-direction: column; } } + +/* ============================================ + Focus Mode Styles + ============================================ */ + +/* Focus Mode Toggle Button */ +.focus-mode-toggle { + display: flex; + align-items: center; + gap: 0.375rem; + background: rgba(147, 51, 234, 0.2); + border: 1px solid #9333ea; + color: #9333ea; + padding: 0.375rem 0.625rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.focus-mode-toggle:hover { + background: rgba(147, 51, 234, 0.3); +} + +.focus-mode-toggle.active { + background: rgba(147, 51, 234, 0.4); + border-color: #a855f7; + color: #a855f7; +} + +.focus-mode-icon { + font-size: 0.875rem; +} + +.focus-mode-label { + font-size: 0.8125rem; + font-weight: 500; +} + +.focus-mode-count { + background: #9333ea; + color: white; + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + border-radius: 10px; + font-weight: 600; + min-width: 18px; + text-align: center; +} + +/* Pin Button in Worker Cards */ +.pin-button { + background: transparent; + border: 1px solid rgba(147, 51, 234, 0.3); + color: #9333ea; + padding: 0.125rem 0.375rem; + border-radius: 3px; + cursor: pointer; + font-size: 0.75rem; + transition: all 0.2s; +} + +.pin-button:hover { + background: rgba(147, 51, 234, 0.2); + border-color: #9333ea; +} + +.pin-button.pinned { + background: rgba(147, 51, 234, 0.3); + border-color: #a855f7; + color: #a855f7; +} + +.worker-card.pinned { + border-left: 3px solid #9333ea; +} + +.worker-card-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Bead Pin Controls in Activity Stream */ +.activity-stream-header { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.bead-pins { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.5rem; + background: var(--bg-secondary); + border-radius: 4px; + border: 1px solid var(--bg-tertiary); +} + +.bead-pins-label { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 600; +} + +.bead-pin-button { + display: flex; + align-items: center; + gap: 0.25rem; + background: rgba(147, 51, 234, 0.1); + border: 1px solid rgba(147, 51, 234, 0.3); + color: #9333ea; + padding: 0.25rem 0.5rem; + border-radius: 3px; + cursor: pointer; + font-size: 0.75rem; + font-family: 'SF Mono', Monaco, monospace; + transition: all 0.2s; +} + +.bead-pin-button:hover { + background: rgba(147, 51, 234, 0.2); + border-color: #9333ea; +} + +.bead-pin-button.pinned { + background: rgba(147, 51, 234, 0.3); + border-color: #a855f7; + color: #a855f7; + font-weight: 600; +} + +.bead-more-indicator { + font-size: 0.7rem; + color: var(--text-secondary); + font-style: italic; +} + +/* Event Item with Bead */ +.event-bead { + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.75rem; + color: #9333ea; + background: rgba(147, 51, 234, 0.1); + padding: 0.125rem 0.375rem; + border-radius: 3px; + margin-right: 0.5rem; +} + +.event-item.bead-pinned { + background: rgba(147, 51, 234, 0.05); + border-left: 2px solid #9333ea; +} + +/* Empty State for Focus Mode */ +.worker-grid .empty-state { + padding: 2rem 1rem; + text-align: center; +} + +.worker-grid .empty-state p:first-child { + font-size: 0.875rem; + color: var(--text-secondary); +} + +/* Responsive adjustments for Focus Mode */ +@media (max-width: 768px) { + .focus-mode-toggle { + padding: 0.25rem 0.5rem; + } + + .focus-mode-label { + display: none; + } + + .bead-pins { + flex-direction: column; + align-items: flex-start; + } + + .bead-pin-button { + font-size: 0.7rem; + padding: 0.2rem 0.4rem; + } +} diff --git a/src/web/frontend/src/types.ts b/src/web/frontend/src/types.ts index 0691466..0805413 100644 --- a/src/web/frontend/src/types.ts +++ b/src/web/frontend/src/types.ts @@ -7,6 +7,7 @@ export interface LogEvent { tool?: string; message: string; raw: string; + bead?: string; // Bead/task identifier for Focus Mode } export interface WorkerInfo { diff --git a/src/web/frontend/test/FocusMode.test.tsx b/src/web/frontend/test/FocusMode.test.tsx new file mode 100644 index 0000000..8de9a82 --- /dev/null +++ b/src/web/frontend/test/FocusMode.test.tsx @@ -0,0 +1,367 @@ +/** + * Tests for Focus Mode functionality + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import WorkerGrid from '../src/components/WorkerGrid'; +import { WorkerInfo } from '../src/types'; + +describe('Focus Mode', () => { + const createMockWorker = (overrides: Partial = {}): WorkerInfo => ({ + id: 'worker-alpha', + lastSeen: new Date().toISOString(), + eventCount: 10, + status: 'active', + recentEvents: [], + ...overrides, + }); + + const mockOnSelectWorker = vi.fn(); + const mockOnTogglePin = vi.fn(); + + beforeEach(() => { + mockOnSelectWorker.mockClear(); + mockOnTogglePin.mockClear(); + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Pin/Unpin Workers', () => { + it('should render pin button for each worker', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + + const { container } = render( + + ); + + expect(container.querySelector('.pin-button')).toBeInTheDocument(); + }); + + it('should call onTogglePin when clicking pin button', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + + const { container } = render( + + ); + + const pinButton = container.querySelector('.pin-button'); + expect(pinButton).toBeTruthy(); + fireEvent.click(pinButton!); + + expect(mockOnTogglePin).toHaveBeenCalledWith('worker-1'); + expect(mockOnSelectWorker).not.toHaveBeenCalled(); // Should not select worker + }); + + it('should show pinned state when worker is pinned', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + const pinnedWorkers = new Set(['worker-1']); + + const { container } = render( + + ); + + const pinButton = container.querySelector('.pin-button.pinned'); + expect(pinButton).toBeInTheDocument(); + expect(pinButton).toHaveTextContent('šŸ“Œ'); + }); + + it('should show unpinned state when worker is not pinned', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + const pinnedWorkers = new Set(); + + const { container } = render( + + ); + + const pinButton = container.querySelector('.pin-button'); + expect(pinButton).toBeInTheDocument(); + expect(pinButton).not.toHaveClass('pinned'); + expect(pinButton).toHaveTextContent('šŸ“'); + }); + + it('should apply pinned class to worker card when pinned', () => { + const workers = [ + createMockWorker({ id: 'worker-1' }), + createMockWorker({ id: 'worker-2' }), + ]; + const pinnedWorkers = new Set(['worker-1']); + + const { container } = render( + + ); + + const cards = container.querySelectorAll('.worker-card'); + expect(cards[0]).toHaveClass('pinned'); + expect(cards[1]).not.toHaveClass('pinned'); + }); + + it('should show correct title on pin button', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + + const { container } = render( + + ); + + const pinButton = container.querySelector('.pin-button'); + expect(pinButton).toHaveAttribute('title', 'Pin worker for Focus Mode'); + }); + + it('should show correct title on unpin button', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + const pinnedWorkers = new Set(['worker-1']); + + const { container } = render( + + ); + + const pinButton = container.querySelector('.pin-button'); + expect(pinButton).toHaveAttribute('title', 'Unpin worker'); + }); + }); + + describe('Focus Mode filtering', () => { + it('should show focus mode indicator when enabled with pinned workers', () => { + const workers = [ + createMockWorker({ id: 'worker-1' }), + createMockWorker({ id: 'worker-2' }), + ]; + const pinnedWorkers = new Set(['worker-1']); + + render( + + ); + + expect(screen.getByText(/Focus: 1 pinned/)).toBeInTheDocument(); + }); + + it('should not show focus mode indicator when disabled', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + const pinnedWorkers = new Set(['worker-1']); + + render( + + ); + + expect(screen.queryByText(/Focus:/)).not.toBeInTheDocument(); + }); + + it('should show empty state message when focus mode enabled with no pinned workers', () => { + const workers: WorkerInfo[] = []; + const pinnedWorkers = new Set(); + + render( + + ); + + expect(screen.getByText(/No pinned workers/)).toBeInTheDocument(); + expect(screen.getByText(/Pin workers to see them in Focus Mode/)).toBeInTheDocument(); + }); + + it('should show helper message to disable focus mode when no pinned workers', () => { + const workers: WorkerInfo[] = []; + const pinnedWorkers = new Set(); + + render( + + ); + + expect(screen.getByText(/Disable Focus Mode to see all workers/)).toBeInTheDocument(); + }); + + it('should show normal empty state when focus mode disabled with no workers', () => { + const workers: WorkerInfo[] = []; + const pinnedWorkers = new Set(); + + render( + + ); + + expect(screen.getByText('No workers detected')).toBeInTheDocument(); + expect(screen.getByText('Waiting for log events...')).toBeInTheDocument(); + }); + }); + + describe('Pin button without onTogglePin callback', () => { + it('should not render pin button when onTogglePin is not provided', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + + const { container } = render( + + ); + + expect(container.querySelector('.pin-button')).not.toBeInTheDocument(); + }); + }); + + describe('Interaction with worker selection', () => { + it('should select worker when clicking card but not pin button', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + + render( + + ); + + // Click on the worker ID (part of the card) + fireEvent.click(screen.getByText('worker-1')); + + expect(mockOnSelectWorker).toHaveBeenCalledWith('worker-1'); + expect(mockOnTogglePin).not.toHaveBeenCalled(); + }); + + it('should pin worker without selecting it', () => { + const workers = [createMockWorker({ id: 'worker-1' })]; + + const { container } = render( + + ); + + const pinButton = container.querySelector('.pin-button'); + expect(pinButton).toBeTruthy(); + fireEvent.click(pinButton!); + + expect(mockOnTogglePin).toHaveBeenCalledWith('worker-1'); + expect(mockOnSelectWorker).not.toHaveBeenCalled(); + }); + }); + + describe('Multiple pinned workers', () => { + it('should show correct count for multiple pinned workers in focus mode', () => { + const workers = [ + createMockWorker({ id: 'worker-1' }), + createMockWorker({ id: 'worker-2' }), + createMockWorker({ id: 'worker-3' }), + ]; + const pinnedWorkers = new Set(['worker-1', 'worker-3']); + + render( + + ); + + expect(screen.getByText(/Focus: 2 pinned/)).toBeInTheDocument(); + }); + + it('should apply pinned class to all pinned workers', () => { + const workers = [ + createMockWorker({ id: 'worker-1' }), + createMockWorker({ id: 'worker-2' }), + createMockWorker({ id: 'worker-3' }), + ]; + const pinnedWorkers = new Set(['worker-1', 'worker-3']); + + const { container } = render( + + ); + + const cards = container.querySelectorAll('.worker-card'); + expect(cards[0]).toHaveClass('pinned'); + expect(cards[1]).not.toHaveClass('pinned'); + expect(cards[2]).toHaveClass('pinned'); + }); + }); +});