@pennyfarthing/cyclist 10.1.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/api/health-score.d.ts.map +1 -1
  2. package/dist/api/health-score.js +12 -3
  3. package/dist/api/health-score.js.map +1 -1
  4. package/dist/api/index.d.ts +1 -1
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +1 -2
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/api/persona.d.ts +2 -0
  9. package/dist/api/persona.d.ts.map +1 -1
  10. package/dist/api/persona.js +19 -1
  11. package/dist/api/persona.js.map +1 -1
  12. package/dist/api/settings.js +1 -1
  13. package/dist/api/settings.js.map +1 -1
  14. package/dist/claude-service.d.ts +8 -2
  15. package/dist/claude-service.d.ts.map +1 -1
  16. package/dist/claude-service.js +21 -2
  17. package/dist/claude-service.js.map +1 -1
  18. package/dist/main.d.ts.map +1 -1
  19. package/dist/main.js +11 -2
  20. package/dist/main.js.map +1 -1
  21. package/dist/plugin-loader.d.ts +49 -0
  22. package/dist/plugin-loader.d.ts.map +1 -0
  23. package/dist/plugin-loader.js +92 -0
  24. package/dist/plugin-loader.js.map +1 -0
  25. package/dist/preload.js +1 -1
  26. package/dist/preload.js.map +1 -1
  27. package/dist/public/css/react.css +1 -1
  28. package/dist/public/js/react/react.js +35 -35
  29. package/dist/server.d.ts.map +1 -1
  30. package/dist/server.js +7 -15
  31. package/dist/server.js.map +1 -1
  32. package/dist/sprint-data.d.ts.map +1 -1
  33. package/dist/sprint-data.js +38 -1
  34. package/dist/sprint-data.js.map +1 -1
  35. package/dist/story-parser.js +1 -1
  36. package/dist/story-parser.js.map +1 -1
  37. package/dist/theme-metadata.js +2 -2
  38. package/dist/theme-metadata.js.map +1 -1
  39. package/dist/websocket.d.ts +0 -6
  40. package/dist/websocket.d.ts.map +1 -1
  41. package/dist/websocket.js +30 -35
  42. package/dist/websocket.js.map +1 -1
  43. package/package.json +2 -1
  44. package/portraits/fifth-element/large/cornelius-54343.png +0 -0
  45. package/portraits/fifth-element/large/diva-53453.png +0 -0
  46. package/portraits/fifth-element/large/korben-34232.png +0 -0
  47. package/portraits/fifth-element/large/leeloo-54333.png +0 -0
  48. package/portraits/fifth-element/large/lindberg-34432.png +0 -0
  49. package/portraits/fifth-element/large/mondoshawan-55131.png +0 -0
  50. package/portraits/fifth-element/large/munro-25321.png +0 -0
  51. package/portraits/fifth-element/large/pacoli-45232.png +0 -0
  52. package/portraits/fifth-element/large/ruby-53544.png +0 -0
  53. package/portraits/fifth-element/large/zorg-45312.png +0 -0
  54. package/portraits/fifth-element/medium/cornelius-54343.png +0 -0
  55. package/portraits/fifth-element/medium/diva-53453.png +0 -0
  56. package/portraits/fifth-element/medium/korben-34232.png +0 -0
  57. package/portraits/fifth-element/medium/leeloo-54333.png +0 -0
  58. package/portraits/fifth-element/medium/lindberg-34432.png +0 -0
  59. package/portraits/fifth-element/medium/mondoshawan-55131.png +0 -0
  60. package/portraits/fifth-element/medium/munro-25321.png +0 -0
  61. package/portraits/fifth-element/medium/pacoli-45232.png +0 -0
  62. package/portraits/fifth-element/medium/ruby-53544.png +0 -0
  63. package/portraits/fifth-element/medium/zorg-45312.png +0 -0
  64. package/src/public/components/AgentPopup.tsx +3 -5
  65. package/src/public/components/ContextSparkline.tsx +56 -0
  66. package/src/public/components/ControlBar.tsx +137 -4
  67. package/src/public/components/HealthGauge.tsx +64 -27
  68. package/src/public/components/PersonaHeader.tsx +46 -3
  69. package/src/public/components/TandemPortrait.tsx +71 -0
  70. package/src/public/components/panels/ACPanel.tsx +1 -1
  71. package/src/public/components/panels/DebugPanel.tsx +50 -57
  72. package/src/public/components/panels/GitPanel.tsx +22 -21
  73. package/src/public/components/panels/MessagePanel.tsx +44 -2
  74. package/src/public/components/panels/SettingsPanel.tsx +4 -4
  75. package/src/public/components/panels/SprintPanel.tsx +199 -144
  76. package/src/public/css/theme-system.css +93 -0
  77. package/src/public/hooks/useHealthScore.ts +6 -14
  78. package/src/public/hooks/usePersona.ts +26 -3
  79. package/src/public/hooks/useSprint.ts +1 -1
  80. package/src/public/styles/tailwind.css +337 -43
  81. package/src/public/utils/slash-commands.ts +1 -17
  82. package/dist/hooks/cyclist-pretooluse-hook.d.ts +0 -60
  83. package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +0 -1
  84. package/dist/hooks/cyclist-pretooluse-hook.js +0 -57
  85. package/dist/hooks/cyclist-pretooluse-hook.js.map +0 -1
  86. package/dist/hooks/pretooluse-hook.d.ts +0 -89
  87. package/dist/hooks/pretooluse-hook.d.ts.map +0 -1
  88. package/dist/hooks/pretooluse-hook.js +0 -235
  89. package/dist/hooks/pretooluse-hook.js.map +0 -1
  90. package/dist/notification-sound.d.ts +0 -59
  91. package/dist/notification-sound.d.ts.map +0 -1
  92. package/dist/notification-sound.js +0 -219
  93. package/dist/notification-sound.js.map +0 -1
  94. package/src/public/types/electron.d.ts +0 -18
@@ -175,18 +175,19 @@ function ContextIndicator({
175
175
  }
176
176
 
177
177
  /**
178
- * Priority dot component - small color-coded circle
178
+ * Priority label component - muted text abbreviation
179
179
  */
180
180
  function PriorityDot({ priority, storyId }: { priority?: string | null; storyId: string }): React.ReactElement | null {
181
181
  if (!priority) return null;
182
- const colorClass = priority === 'P0' ? 'priority-p0' : priority === 'P1' ? 'priority-p1' : 'priority-p2';
183
182
  return (
184
183
  <span
185
- className={`priority-dot ${colorClass}`}
184
+ className="priority-label"
186
185
  data-testid={`story-priority-${storyId}`}
187
186
  data-priority={priority}
188
187
  title={priority}
189
- />
188
+ >
189
+ {priority}
190
+ </span>
190
191
  );
191
192
  }
192
193
 
@@ -239,6 +240,158 @@ function JiraLink({ jiraKey, storyId }: { jiraKey: string; storyId: string }): R
239
240
  );
240
241
  }
241
242
 
243
+ /**
244
+ * EpicGroup - Renders a single epic with its stories
245
+ */
246
+ function EpicGroup({
247
+ epic,
248
+ isExpanded,
249
+ isArchiving,
250
+ onToggle,
251
+ onKeyDown,
252
+ onArchive,
253
+ }: {
254
+ epic: SprintEpic;
255
+ isExpanded: boolean;
256
+ isArchiving: boolean;
257
+ onToggle: (id: string) => void;
258
+ onKeyDown: (id: string, e: React.KeyboardEvent) => void;
259
+ onArchive: (id: string) => void;
260
+ }): React.ReactElement {
261
+ const { done, total } = calculateEpicProgress(epic);
262
+ const completed = isEpicCompleted(epic);
263
+
264
+ return (
265
+ <div
266
+ className={`epic-group ${completed ? 'epic-completed' : ''}`}
267
+ data-testid={`epic-group-${epic.id}`}
268
+ >
269
+ {/* Epic Header */}
270
+ <div className="epic-header">
271
+ <Button
272
+ variant="ghost"
273
+ size="icon"
274
+ className="epic-toggle"
275
+ data-testid={`epic-toggle-${epic.id}`}
276
+ onClick={() => onToggle(epic.id)}
277
+ onKeyDown={(e) => onKeyDown(epic.id, e)}
278
+ aria-expanded={isExpanded}
279
+ >
280
+ {isExpanded ? '▼' : '▶'}
281
+ </Button>
282
+ <span className="epic-title">{epic.title}</span>
283
+ {epic.jiraKey && <span className="epic-jira">{epic.jiraKey}</span>}
284
+ <ContextIndicator hasContext={epic.hasContext ?? false} testIdPrefix="epic" id={epic.id} />
285
+ {completed && epic.hasContext && (
286
+ <Badge variant="default" className="epic-ready-badge" data-testid={`epic-ready-badge-${epic.id}`}>
287
+ Ready
288
+ </Badge>
289
+ )}
290
+
291
+ {/* Progress bar */}
292
+ <div
293
+ className="epic-progress"
294
+ data-testid={`epic-progress-${epic.id}`}
295
+ data-done={String(done)}
296
+ data-total={String(total)}
297
+ >
298
+ <div
299
+ className="progress-bar"
300
+ style={{ width: `${total > 0 ? (done / total) * 100 : 0}%` }}
301
+ />
302
+ </div>
303
+ <span
304
+ className="epic-progress-label"
305
+ data-testid={`epic-progress-label-${epic.id}`}
306
+ >
307
+ {done}/{total} pts
308
+ </span>
309
+
310
+ {/* Archive button for completed epics */}
311
+ {completed && (
312
+ <>
313
+ {isArchiving && (
314
+ <span data-testid={`archive-loading-${epic.id}`}>...</span>
315
+ )}
316
+ <Button
317
+ variant="outline"
318
+ size="sm"
319
+ className="archive-button"
320
+ data-testid={`archive-button-${epic.id}`}
321
+ aria-label={`Archive ${epic.id}`}
322
+ disabled={isArchiving}
323
+ onClick={() => onArchive(epic.id)}
324
+ >
325
+ Archive
326
+ </Button>
327
+ </>
328
+ )}
329
+ </div>
330
+
331
+ {/* Stories list (collapsible) */}
332
+ {isExpanded && (
333
+ <div className="epic-stories">
334
+ {epic.stories.map((story) => {
335
+ const hasContext = story.hasContext ?? false;
336
+ const isBlocked = story.status === 'blocked';
337
+ const assigneeDisplay = formatAssignee(story.assignedTo);
338
+ return (
339
+ <div
340
+ key={story.id}
341
+ className={`story-item ${!hasContext ? 'missing-context' : ''} ${isBlocked ? 'story-blocked' : ''}`}
342
+ data-testid={`story-item-${story.id}`}
343
+ data-status={story.status}
344
+ data-story-id={story.id}
345
+ aria-label={`${story.id}: ${story.title}`}
346
+ >
347
+ <PriorityDot priority={story.priority} storyId={story.id} />
348
+ <StatusBadge status={story.status} storyId={story.id} />
349
+ {story.jiraKey && <JiraLink jiraKey={story.jiraKey} storyId={story.id} />}
350
+ <div className="story-info">
351
+ <span className="story-title">{story.title}</span>
352
+ <span className="story-meta">
353
+ {assigneeDisplay && (
354
+ <span
355
+ className="story-assignee"
356
+ data-testid={`story-assignee-${story.id}`}
357
+ >
358
+ {assigneeDisplay}
359
+ </span>
360
+ )}
361
+ {story.workflow && (
362
+ <span
363
+ className="story-workflow-badge"
364
+ data-testid={`story-workflow-${story.id}`}
365
+ >
366
+ {story.workflow}
367
+ </span>
368
+ )}
369
+ {story.status === 'done' && story.completed && (
370
+ <span
371
+ className="story-completed-date"
372
+ data-testid={`story-completed-${story.id}`}
373
+ >
374
+ {story.completed}
375
+ </span>
376
+ )}
377
+ </span>
378
+ </div>
379
+ <ContextIndicator hasContext={hasContext} testIdPrefix="story" id={story.id} />
380
+ <span
381
+ className="story-points"
382
+ data-testid={`story-points-${story.id}`}
383
+ >
384
+ {story.points}
385
+ </span>
386
+ </div>
387
+ );
388
+ })}
389
+ </div>
390
+ )}
391
+ </div>
392
+ );
393
+ }
394
+
242
395
  /**
243
396
  * EnhancedSprintPanel - Full sprint management with epic actions
244
397
  */
@@ -250,12 +403,17 @@ export function EnhancedSprintPanel(): React.ReactElement {
250
403
  const [confirmArchive, setConfirmArchive] = useState<string | null>(null);
251
404
  const [actionError, setActionError] = useState<Error | null>(null);
252
405
 
253
- // Expand all epics by default when data first loads (once only)
406
+ // Split epics into active (has non-done stories) vs completed (all stories done)
407
+ const activeEpics = data?.epics.filter((e) => !isEpicCompleted(e)) ?? [];
408
+ const completedEpics = data?.epics.filter((e) => isEpicCompleted(e)) ?? [];
409
+
410
+ // Expand only active epics by default when data first loads (once only)
411
+ // Completed epics start collapsed
254
412
  const hasInitializedExpansion = useRef(false);
255
413
  useEffect(() => {
256
414
  if (data?.epics && !hasInitializedExpansion.current) {
257
415
  hasInitializedExpansion.current = true;
258
- setExpandedEpics(new Set(data.epics.map((e) => e.id)));
416
+ setExpandedEpics(new Set(activeEpics.map((e) => e.id)));
259
417
  }
260
418
  }, [data?.epics]);
261
419
 
@@ -413,7 +571,7 @@ export function EnhancedSprintPanel(): React.ReactElement {
413
571
 
414
572
  <Separator className="my-2" />
415
573
 
416
- {/* Section 2: Epic Tree View */}
574
+ {/* Section 2: Active Epics */}
417
575
  <section data-section="epics">
418
576
  <h2>Current Epics</h2>
419
577
  <div data-testid="epic-tree-view">
@@ -423,146 +581,43 @@ export function EnhancedSprintPanel(): React.ReactElement {
423
581
  <p className="hint">Promote an epic from Future Initiatives to get started</p>
424
582
  </div>
425
583
  )}
426
- {data?.epics.map((epic) => {
427
- const { done, total } = calculateEpicProgress(epic);
428
- const completed = isEpicCompleted(epic);
429
- const isExpanded = expandedEpics.has(epic.id);
430
- const isArchiving = loadingActions.has(`archive-${epic.id}`);
431
-
432
- return (
433
- <div
434
- key={epic.id}
435
- className={`epic-group ${completed ? 'epic-completed' : ''}`}
436
- data-testid={`epic-group-${epic.id}`}
437
- >
438
- {/* Epic Header */}
439
- <div className="epic-header">
440
- <Button
441
- variant="ghost"
442
- size="icon"
443
- className="epic-toggle"
444
- data-testid={`epic-toggle-${epic.id}`}
445
- onClick={() => toggleEpic(epic.id)}
446
- onKeyDown={(e) => handleEpicKeyDown(epic.id, e)}
447
- aria-expanded={isExpanded}
448
- >
449
- {isExpanded ? '▼' : '▶'}
450
- </Button>
451
- <span className="epic-title">{epic.title}</span>
452
- {epic.jiraKey && <span className="epic-jira">{epic.jiraKey}</span>}
453
- <ContextIndicator hasContext={epic.hasContext ?? false} testIdPrefix="epic" id={epic.id} />
454
- {completed && epic.hasContext && (
455
- <Badge variant="default" className="epic-ready-badge" data-testid={`epic-ready-badge-${epic.id}`}>
456
- Ready
457
- </Badge>
458
- )}
459
-
460
- {/* Progress bar */}
461
- <div
462
- className="epic-progress"
463
- data-testid={`epic-progress-${epic.id}`}
464
- data-done={String(done)}
465
- data-total={String(total)}
466
- >
467
- <div
468
- className="progress-bar"
469
- style={{ width: `${total > 0 ? (done / total) * 100 : 0}%` }}
470
- />
471
- </div>
472
- <span
473
- className="epic-progress-label"
474
- data-testid={`epic-progress-label-${epic.id}`}
475
- >
476
- {done}/{total} pts
477
- </span>
478
-
479
- {/* Archive button for completed epics */}
480
- {completed && (
481
- <>
482
- {isArchiving && (
483
- <span data-testid={`archive-loading-${epic.id}`}>...</span>
484
- )}
485
- <Button
486
- variant="outline"
487
- size="sm"
488
- className="archive-button"
489
- data-testid={`archive-button-${epic.id}`}
490
- aria-label={`Archive ${epic.id}`}
491
- disabled={isArchiving}
492
- onClick={() => setConfirmArchive(epic.id)}
493
- >
494
- Archive
495
- </Button>
496
- </>
497
- )}
498
- </div>
499
-
500
- {/* Stories list (collapsible) */}
501
- {isExpanded && (
502
- <div className="epic-stories">
503
- {epic.stories.map((story) => {
504
- const hasContext = story.hasContext ?? false;
505
- const isBlocked = story.status === 'blocked';
506
- const assigneeDisplay = formatAssignee(story.assignedTo);
507
- return (
508
- <div
509
- key={story.id}
510
- className={`story-item ${!hasContext ? 'missing-context' : ''} ${isBlocked ? 'story-blocked' : ''}`}
511
- data-testid={`story-item-${story.id}`}
512
- data-status={story.status}
513
- data-story-id={story.id}
514
- aria-label={`${story.id}: ${story.title}`}
515
- >
516
- <PriorityDot priority={story.priority} storyId={story.id} />
517
- <StatusBadge status={story.status} storyId={story.id} />
518
- {story.jiraKey && <JiraLink jiraKey={story.jiraKey} storyId={story.id} />}
519
- <div className="story-info">
520
- <span className="story-title">{story.title}</span>
521
- <span className="story-meta">
522
- {assigneeDisplay && (
523
- <span
524
- className="story-assignee"
525
- data-testid={`story-assignee-${story.id}`}
526
- >
527
- {assigneeDisplay}
528
- </span>
529
- )}
530
- {story.workflow && (
531
- <span
532
- className="story-workflow-badge"
533
- data-testid={`story-workflow-${story.id}`}
534
- >
535
- {story.workflow}
536
- </span>
537
- )}
538
- {story.status === 'done' && story.completed && (
539
- <span
540
- className="story-completed-date"
541
- data-testid={`story-completed-${story.id}`}
542
- >
543
- {story.completed}
544
- </span>
545
- )}
546
- </span>
547
- </div>
548
- <ContextIndicator hasContext={hasContext} testIdPrefix="story" id={story.id} />
549
- <span
550
- className="story-points"
551
- data-testid={`story-points-${story.id}`}
552
- >
553
- {story.points}
554
- </span>
555
- </div>
556
- );
557
- })}
558
- </div>
559
- )}
560
- </div>
561
- );
562
- })}
584
+ {activeEpics.map((epic) => (
585
+ <EpicGroup
586
+ key={epic.id}
587
+ epic={epic}
588
+ isExpanded={expandedEpics.has(epic.id)}
589
+ isArchiving={loadingActions.has(`archive-${epic.id}`)}
590
+ onToggle={toggleEpic}
591
+ onKeyDown={handleEpicKeyDown}
592
+ onArchive={setConfirmArchive}
593
+ />
594
+ ))}
563
595
  </div>
564
596
  </section>
565
597
 
598
+ {/* Section 2b: Completed Epics */}
599
+ {completedEpics.length > 0 && (
600
+ <>
601
+ <Separator className="my-2" />
602
+ <section data-section="completed-epics">
603
+ <h2>Completed Epics</h2>
604
+ <div data-testid="completed-epics-section">
605
+ {completedEpics.map((epic) => (
606
+ <EpicGroup
607
+ key={epic.id}
608
+ epic={epic}
609
+ isExpanded={expandedEpics.has(epic.id)}
610
+ isArchiving={loadingActions.has(`archive-${epic.id}`)}
611
+ onToggle={toggleEpic}
612
+ onKeyDown={handleEpicKeyDown}
613
+ onArchive={setConfirmArchive}
614
+ />
615
+ ))}
616
+ </div>
617
+ </section>
618
+ </>
619
+ )}
620
+
566
621
  <Separator className="my-2" />
567
622
 
568
623
  {/* Section 3: Future Initiatives */}
@@ -535,3 +535,96 @@
535
535
  .tool-stack-content .tool-historical {
536
536
  opacity: 0.85;
537
537
  }
538
+
539
+ /* =============================================================================
540
+ ACPanel Styling (MSSCI-14763) - Tufte Treatment
541
+ ============================================================================= */
542
+
543
+ /* Panel container */
544
+ .ac-panel {
545
+ padding: 0.5rem;
546
+ }
547
+
548
+ /* Content wrapper — Tufte left border accent */
549
+ .ac-content {
550
+ border-left: 2px solid var(--border);
551
+ padding-left: 0.5rem;
552
+ transition: border-color 0.15s ease;
553
+ }
554
+
555
+ .ac-content:hover {
556
+ border-left-color: var(--accent);
557
+ }
558
+
559
+ /* Progress counter — above the bar, not overlaid */
560
+ .ac-panel .progress-text {
561
+ font-size: 0.6875rem;
562
+ font-family: var(--font-mono);
563
+ font-variant-numeric: tabular-nums;
564
+ color: var(--text-muted);
565
+ margin-bottom: 0.25rem;
566
+ display: block;
567
+ }
568
+
569
+ /* Progress bar — Tufte: thin line, no rounded corners */
570
+ .ac-panel .progress-bar-container {
571
+ position: relative;
572
+ height: 4px;
573
+ background: var(--border);
574
+ border-radius: 0;
575
+ margin-bottom: 0.5rem;
576
+ overflow: hidden;
577
+ }
578
+
579
+ .ac-panel .progress-bar {
580
+ height: 100%;
581
+ background: var(--accent);
582
+ border-radius: 0;
583
+ transition: width 0.3s ease;
584
+ }
585
+
586
+ /* Criteria list */
587
+ .ac-list {
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: 0.125rem;
591
+ }
592
+
593
+ /* Individual criterion */
594
+ .ac-item {
595
+ display: flex;
596
+ align-items: baseline;
597
+ gap: 0.375rem;
598
+ padding: 0.125rem 0;
599
+ font-size: 0.8125rem;
600
+ color: var(--text-primary);
601
+ }
602
+
603
+ .ac-item.ac-done {
604
+ color: var(--text-muted);
605
+ }
606
+
607
+ /* Status icon */
608
+ .ac-icon {
609
+ width: 1rem;
610
+ text-align: center;
611
+ flex-shrink: 0;
612
+ font-size: 0.75rem;
613
+ color: var(--text-secondary);
614
+ }
615
+
616
+ .ac-done .ac-icon {
617
+ color: var(--status-success);
618
+ }
619
+
620
+ /* Criterion text */
621
+ .ac-text {
622
+ flex: 1;
623
+ min-width: 0;
624
+ line-height: 1.4;
625
+ }
626
+
627
+ .ac-done .ac-text {
628
+ text-decoration: line-through;
629
+ text-decoration-color: var(--text-muted);
630
+ }
@@ -18,17 +18,16 @@ export interface UseHealthScoreReturn {
18
18
  data: HealthScoreData | null;
19
19
  isLoading: boolean;
20
20
  error: Error | null;
21
+ lastFetchedAt: number | null;
21
22
  refresh: () => void;
22
23
  }
23
24
 
24
- const POLL_INTERVAL = 60_000;
25
-
26
25
  export function useHealthScore(): UseHealthScoreReturn {
27
26
  const [data, setData] = useState<HealthScoreData | null>(null);
28
27
  const [isLoading, setIsLoading] = useState(false);
29
28
  const [error, setError] = useState<Error | null>(null);
29
+ const [lastFetchedAt, setLastFetchedAt] = useState<number | null>(null);
30
30
  const abortRef = useRef<AbortController | null>(null);
31
- const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
32
31
 
33
32
  const refresh = useCallback(() => {
34
33
  if (abortRef.current) {
@@ -50,6 +49,7 @@ export function useHealthScore(): UseHealthScoreReturn {
50
49
  })
51
50
  .then((json: HealthScoreData) => {
52
51
  setData(json);
52
+ setLastFetchedAt(Date.now());
53
53
  setIsLoading(false);
54
54
  })
55
55
  .catch((err) => {
@@ -59,19 +59,11 @@ export function useHealthScore(): UseHealthScoreReturn {
59
59
  });
60
60
  }, []);
61
61
 
62
- // Auto-poll on mount
63
62
  useEffect(() => {
64
- intervalRef.current = setInterval(refresh, POLL_INTERVAL);
65
-
66
63
  return () => {
67
- if (abortRef.current) {
68
- abortRef.current.abort();
69
- }
70
- if (intervalRef.current) {
71
- clearInterval(intervalRef.current);
72
- }
64
+ abortRef.current?.abort();
73
65
  };
74
- }, [refresh]);
66
+ }, []);
75
67
 
76
- return { data, isLoading, error, refresh };
68
+ return { data, isLoading, error, lastFetchedAt, refresh };
77
69
  }
@@ -13,22 +13,33 @@
13
13
 
14
14
  import { useState, useEffect, useRef } from 'react';
15
15
 
16
+ export interface TandemAgentData {
17
+ character: string;
18
+ role: string;
19
+ slug: string;
20
+ theme: string;
21
+ isThinking: boolean;
22
+ }
23
+
16
24
  export interface PersonaData {
17
25
  character: string | null;
18
26
  theme: string | null;
19
27
  role: string | null;
20
28
  slug: string | null;
21
29
  quote: string | null; // Random catchphrase from theme
30
+ tandemAgent?: TandemAgentData | null;
22
31
  }
23
32
 
24
33
  interface UsePersonaResult {
25
34
  persona: PersonaData | null;
35
+ isStreaming: boolean;
26
36
  isLoading: boolean;
27
37
  error: Error | null;
28
38
  }
29
39
 
30
40
  export function usePersona(): UsePersonaResult {
31
41
  const [persona, setPersona] = useState<PersonaData | null>(null);
42
+ const [isStreaming, setIsStreaming] = useState(false);
32
43
  const [isLoading, setIsLoading] = useState(true);
33
44
  const [error, setError] = useState<Error | null>(null);
34
45
  const wsRef = useRef<WebSocket | null>(null);
@@ -48,8 +59,19 @@ export function usePersona(): UsePersonaResult {
48
59
 
49
60
  wsRef.current.onmessage = (event) => {
50
61
  try {
51
- const data = JSON.parse(event.data) as PersonaData;
52
- setPersona(data);
62
+ const data = JSON.parse(event.data);
63
+
64
+ // Story 94-1: Handle streaming state updates
65
+ if (data.type === 'streaming') {
66
+ setIsStreaming(data.isStreaming ?? false);
67
+ return;
68
+ }
69
+
70
+ // Persona data (initial or agent change) — extract isStreaming if present
71
+ if (data.isStreaming !== undefined) {
72
+ setIsStreaming(data.isStreaming);
73
+ }
74
+ setPersona(data as PersonaData);
53
75
  setIsLoading(false);
54
76
  setError(null);
55
77
  } catch (err) {
@@ -59,6 +81,7 @@ export function usePersona(): UsePersonaResult {
59
81
 
60
82
  wsRef.current.onclose = () => {
61
83
  console.debug('[usePersona] WebSocket closed, reconnecting...');
84
+ setIsStreaming(false);
62
85
  reconnectTimeoutRef.current = setTimeout(connect, 2000);
63
86
  };
64
87
 
@@ -85,5 +108,5 @@ export function usePersona(): UsePersonaResult {
85
108
  };
86
109
  }, []);
87
110
 
88
- return { persona, isLoading, error };
111
+ return { persona, isStreaming, isLoading, error };
89
112
  }
@@ -100,7 +100,7 @@ export function useSprint(): UseSprintResult {
100
100
  const msg = JSON.parse(event.data) as SprintMessage;
101
101
  if (msg.type === 'init' || msg.type === 'update') {
102
102
  // Extract data, excluding type field
103
- const { type, ...sprintData } = msg;
103
+ const { type: _type, ...sprintData } = msg;
104
104
  setData((prev) => {
105
105
  if (!prev) return sprintData as SprintData;
106
106
  // Merge partial updates