@pennyfarthing/cyclist 10.0.3 → 10.1.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 (91) hide show
  1. package/dist/api/agent-load.d.ts +3 -0
  2. package/dist/api/agent-load.d.ts.map +1 -0
  3. package/dist/api/agent-load.js +124 -0
  4. package/dist/api/agent-load.js.map +1 -0
  5. package/dist/api/code-markers.d.ts +9 -0
  6. package/dist/api/code-markers.d.ts.map +1 -0
  7. package/dist/api/code-markers.js +62 -0
  8. package/dist/api/code-markers.js.map +1 -0
  9. package/dist/api/complexity.d.ts +3 -0
  10. package/dist/api/complexity.d.ts.map +1 -0
  11. package/dist/api/complexity.js +47 -0
  12. package/dist/api/complexity.js.map +1 -0
  13. package/dist/api/dead-code.d.ts +3 -0
  14. package/dist/api/dead-code.d.ts.map +1 -0
  15. package/dist/api/dead-code.js +70 -0
  16. package/dist/api/dead-code.js.map +1 -0
  17. package/dist/api/dependencies.d.ts +3 -0
  18. package/dist/api/dependencies.d.ts.map +1 -0
  19. package/dist/api/dependencies.js +43 -0
  20. package/dist/api/dependencies.js.map +1 -0
  21. package/dist/api/git.d.ts +3 -2
  22. package/dist/api/git.d.ts.map +1 -1
  23. package/dist/api/git.js +11 -6
  24. package/dist/api/git.js.map +1 -1
  25. package/dist/api/health-score.d.ts +3 -0
  26. package/dist/api/health-score.d.ts.map +1 -0
  27. package/dist/api/health-score.js +38 -0
  28. package/dist/api/health-score.js.map +1 -0
  29. package/dist/api/hotspots.d.ts.map +1 -1
  30. package/dist/api/hotspots.js +9 -1
  31. package/dist/api/hotspots.js.map +1 -1
  32. package/dist/api/index.d.ts +6 -0
  33. package/dist/api/index.d.ts.map +1 -1
  34. package/dist/api/index.js +11 -0
  35. package/dist/api/index.js.map +1 -1
  36. package/dist/git-diff.d.ts.map +1 -1
  37. package/dist/git-diff.js +6 -5
  38. package/dist/git-diff.js.map +1 -1
  39. package/dist/preload.js +11 -0
  40. package/dist/preload.js.map +1 -1
  41. package/dist/prime.d.ts +3 -2
  42. package/dist/prime.d.ts.map +1 -1
  43. package/dist/prime.js +25 -8
  44. package/dist/prime.js.map +1 -1
  45. package/dist/public/css/react.css +1 -1
  46. package/dist/public/js/react/react.js +50 -39
  47. package/dist/server.d.ts.map +1 -1
  48. package/dist/server.js +12 -1
  49. package/dist/server.js.map +1 -1
  50. package/dist/sprint-data.d.ts +6 -0
  51. package/dist/sprint-data.d.ts.map +1 -1
  52. package/dist/sprint-data.js +80 -66
  53. package/dist/sprint-data.js.map +1 -1
  54. package/dist/websocket.d.ts.map +1 -1
  55. package/dist/websocket.js +6 -5
  56. package/dist/websocket.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/public/App.tsx +0 -2
  59. package/src/public/components/AgentLoadDialog.tsx +202 -0
  60. package/src/public/components/ControlBar.tsx +4 -3
  61. package/src/public/components/DeadCodeDialog.tsx +169 -0
  62. package/src/public/components/DockviewWorkspace.tsx +0 -3
  63. package/src/public/components/FullFileTree.tsx +18 -4
  64. package/src/public/components/HealthGauge.tsx +144 -0
  65. package/src/public/components/MessageView.tsx +23 -6
  66. package/src/public/components/ToolCallBlock.tsx +21 -6
  67. package/src/public/components/dialogs/CodeMarkersDialog.tsx +169 -0
  68. package/src/public/components/dialogs/ComplexityDialog.tsx +163 -0
  69. package/src/public/components/dialogs/DependenciesDialog.tsx +120 -0
  70. package/src/public/components/dialogs/HotspotsDialog.tsx +451 -0
  71. package/src/public/components/dialogs/ToolDialog.tsx +43 -0
  72. package/src/public/components/panels/AcceptanceCriteriaPanel.tsx +15 -30
  73. package/src/public/components/panels/DebugPanel.tsx +83 -0
  74. package/src/public/components/panels/GitPanel.tsx +12 -18
  75. package/src/public/components/panels/SprintPanel.tsx +84 -15
  76. package/src/public/components/panels/index.ts +0 -1
  77. package/src/public/components/ui/dialog.tsx +3 -3
  78. package/src/public/css/theme-system.css +5 -11
  79. package/src/public/hooks/index.ts +4 -0
  80. package/src/public/hooks/useAgentLoad.ts +105 -0
  81. package/src/public/hooks/useCodeMarkers.ts +101 -0
  82. package/src/public/hooks/useColorScheme.ts +25 -10
  83. package/src/public/hooks/useComplexity.ts +80 -0
  84. package/src/public/hooks/useDeadCode.ts +99 -0
  85. package/src/public/hooks/useDependencies.ts +82 -0
  86. package/src/public/hooks/useHealthScore.ts +77 -0
  87. package/src/public/hooks/useHotspots.ts +11 -1
  88. package/src/public/hooks/useSprint.ts +6 -0
  89. package/src/public/styles/tailwind.css +90 -78
  90. package/src/public/utils/messageFilters.ts +77 -6
  91. package/src/public/utils/slash-commands.ts +2 -18
@@ -12,6 +12,14 @@ import React, { useState, useEffect } from 'react';
12
12
  import { Button } from '@/components/ui/button';
13
13
  import { Badge } from '@/components/ui/badge';
14
14
  import { Separator } from '@/components/ui/separator';
15
+ import { HotspotsDialog } from '../dialogs/HotspotsDialog';
16
+ import { CodeMarkersDialog } from '../dialogs/CodeMarkersDialog';
17
+ import { ComplexityDialog } from '../dialogs/ComplexityDialog';
18
+ import { DependenciesDialog } from '../dialogs/DependenciesDialog';
19
+ import { AgentLoadDialog } from '../AgentLoadDialog';
20
+ import { DeadCodeDialog } from '../DeadCodeDialog';
21
+ import { HealthGauge } from '../HealthGauge';
22
+ import { useHealthScore } from '../../hooks/useHealthScore';
15
23
 
16
24
  /** Context tier type */
17
25
  type ContextTier = 'FULL' | 'REFRESH' | 'HANDOFF' | 'MINIMAL';
@@ -94,6 +102,13 @@ export function DebugPanel(): React.ReactElement {
94
102
  const [context, setContext] = useState<ContextData | null>(null);
95
103
  const [tokenStats, setTokenStats] = useState<Record<string, unknown> | null>(null);
96
104
  const [breakdownExpanded, setBreakdownExpanded] = useState(false);
105
+ const [hotspotsOpen, setHotspotsOpen] = useState(false);
106
+ const [codeMarkersOpen, setCodeMarkersOpen] = useState(false);
107
+ const [complexityOpen, setComplexityOpen] = useState(false);
108
+ const [dependenciesOpen, setDependenciesOpen] = useState(false);
109
+ const [agentLoadOpen, setAgentLoadOpen] = useState(false);
110
+ const [deadCodeOpen, setDeadCodeOpen] = useState(false);
111
+ const healthScore = useHealthScore();
97
112
 
98
113
  useEffect(() => {
99
114
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -134,6 +149,14 @@ export function DebugPanel(): React.ReactElement {
134
149
 
135
150
  return (
136
151
  <div className="debug-panel" data-testid="debug-panel">
152
+ <HealthGauge
153
+ score={healthScore.data?.composite_score ?? null}
154
+ dimensions={healthScore.data?.dimensions ?? []}
155
+ totalDimensions={8}
156
+ />
157
+
158
+ <Separator className="my-3" />
159
+
137
160
  <h4>Context Usage</h4>
138
161
  {context ? (
139
162
  <div className="context-info">
@@ -261,6 +284,66 @@ export function DebugPanel(): React.ReactElement {
261
284
  <div className="placeholder">No token stats</div>
262
285
  )}
263
286
 
287
+ <Separator className="my-3" />
288
+
289
+ <h4>Tools</h4>
290
+ <div className="tool-launcher" data-testid="tool-launcher">
291
+ <Button
292
+ variant="outline"
293
+ size="sm"
294
+ onClick={() => setHotspotsOpen(true)}
295
+ data-testid="tool-launcher-hotspots"
296
+ >
297
+ Hotspots
298
+ </Button>
299
+ <Button
300
+ variant="outline"
301
+ size="sm"
302
+ onClick={() => setCodeMarkersOpen(true)}
303
+ data-testid="tool-launcher-codemarkers"
304
+ >
305
+ Code Markers
306
+ </Button>
307
+ <Button
308
+ variant="outline"
309
+ size="sm"
310
+ onClick={() => setDeadCodeOpen(true)}
311
+ data-testid="tool-launcher-deadcode"
312
+ >
313
+ Dead Code
314
+ </Button>
315
+ <Button
316
+ variant="outline"
317
+ size="sm"
318
+ onClick={() => setComplexityOpen(true)}
319
+ data-testid="tool-launcher-complexity"
320
+ >
321
+ Complexity
322
+ </Button>
323
+ <Button
324
+ variant="outline"
325
+ size="sm"
326
+ onClick={() => setDependenciesOpen(true)}
327
+ data-testid="tool-launcher-dependencies"
328
+ >
329
+ Dependencies
330
+ </Button>
331
+ <Button
332
+ variant="outline"
333
+ size="sm"
334
+ onClick={() => setAgentLoadOpen(true)}
335
+ data-testid="tool-launcher-agent-load"
336
+ >
337
+ Analyze All Agents
338
+ </Button>
339
+ </div>
340
+
341
+ <HotspotsDialog open={hotspotsOpen} onOpenChange={setHotspotsOpen} />
342
+ <CodeMarkersDialog open={codeMarkersOpen} onOpenChange={setCodeMarkersOpen} />
343
+ <ComplexityDialog open={complexityOpen} onOpenChange={setComplexityOpen} />
344
+ <DependenciesDialog open={dependenciesOpen} onOpenChange={setDependenciesOpen} />
345
+ <AgentLoadDialog isOpen={agentLoadOpen} onClose={() => setAgentLoadOpen(false)} />
346
+ <DeadCodeDialog isOpen={deadCodeOpen} onClose={() => setDeadCodeOpen(false)} />
264
347
  </div>
265
348
  );
266
349
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import React, { useState } from 'react';
9
+ import { RefreshCw } from 'lucide-react';
9
10
  import { Button } from '@/components/ui/button';
10
11
  import { Badge } from '@/components/ui/badge';
11
12
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -90,14 +91,14 @@ function RepoStatus({ repo, onPullDevelop }: RepoStatusProps): React.ReactElemen
90
91
  <span className="branch-name">{branch}</span>
91
92
  </div>
92
93
 
93
- {(ahead !== undefined && ahead > 0) || (behind !== undefined && behind > 0) ? (
94
+ {((ahead !== undefined && ahead > 0) || (behind !== undefined && behind > 0) || hasDevelopUpdates) && (
94
95
  <div className="sync-status">
95
96
  {ahead !== undefined && ahead > 0 && (
96
97
  <Tooltip>
97
98
  <TooltipTrigger asChild>
98
99
  <span className="ahead">↑{ahead}</span>
99
100
  </TooltipTrigger>
100
- <TooltipContent>Commits ahead</TooltipContent>
101
+ <TooltipContent>Commits ahead of remote</TooltipContent>
101
102
  </Tooltip>
102
103
  )}
103
104
  {behind !== undefined && behind > 0 && (
@@ -105,29 +106,22 @@ function RepoStatus({ repo, onPullDevelop }: RepoStatusProps): React.ReactElemen
105
106
  <TooltipTrigger asChild>
106
107
  <span className="behind">↓{behind}</span>
107
108
  </TooltipTrigger>
108
- <TooltipContent>Commits behind</TooltipContent>
109
+ <TooltipContent>Commits behind remote</TooltipContent>
109
110
  </Tooltip>
110
111
  )}
111
- </div>
112
- ) : null}
113
-
114
- {hasDevelopUpdates && (
115
- <div className="develop-behind-warning">
116
- <span className="warning-icon">⚠️</span>
117
- <span className="warning-text">develop is {developBehind} commit{developBehind > 1 ? 's' : ''} ahead</span>
118
- {onPullDevelop && (
112
+ {hasDevelopUpdates && onPullDevelop && (
119
113
  <Tooltip>
120
114
  <TooltipTrigger asChild>
121
- <Button
122
- variant="outline"
123
- size="sm"
124
- className="pull-develop-btn"
115
+ <button
116
+ className="sync-develop-btn"
125
117
  onClick={() => onPullDevelop(name, path)}
118
+ aria-label={`Pull ${developBehind} commits from develop`}
126
119
  >
127
- Pull
128
- </Button>
120
+ <RefreshCw size={12} />
121
+ <span>{developBehind}</span>
122
+ </button>
129
123
  </TooltipTrigger>
130
- <TooltipContent>Pull latest from develop</TooltipContent>
124
+ <TooltipContent>develop is {developBehind} commit{developBehind > 1 ? 's' : ''} ahead — click to pull</TooltipContent>
131
125
  </Tooltip>
132
126
  )}
133
127
  </div>
@@ -5,7 +5,8 @@
5
5
  * Story MSSCI-14189 - Enhanced Sprint Panel with story management and epic actions
6
6
  */
7
7
 
8
- import React, { useState, useEffect, useCallback } from 'react';
8
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
9
+ import { Check, Loader, Circle, AlertTriangle } from 'lucide-react';
9
10
  import { Button } from '@/components/ui/button';
10
11
  import { Badge } from '@/components/ui/badge';
11
12
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -85,6 +86,21 @@ export function SprintPanel(): React.ReactElement {
85
86
  // Enhanced Sprint Panel (MSSCI-14189)
86
87
  // =============================================================================
87
88
 
89
+ /**
90
+ * Format email to short display name: "keith.avery@..." -> "K. Avery"
91
+ */
92
+ function formatAssignee(email: string | null | undefined): string | null {
93
+ if (!email) return null;
94
+ const local = email.split('@')[0];
95
+ const parts = local.split('.');
96
+ if (parts.length >= 2) {
97
+ const first = parts[0].charAt(0).toUpperCase();
98
+ const last = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1);
99
+ return `${first}. ${last}`;
100
+ }
101
+ return local;
102
+ }
103
+
88
104
  /**
89
105
  * Calculate epic progress (done points / total points)
90
106
  */
@@ -106,17 +122,18 @@ function isEpicCompleted(epic: SprintEpic): boolean {
106
122
  /**
107
123
  * Get status badge content and class for a story status
108
124
  */
109
- function getStatusBadgeInfo(status: SprintStory['status']): { icon: string; className: string } {
125
+ function getStatusBadgeInfo(status: SprintStory['status']): { icon: React.ReactElement; className: string } {
126
+ const size = 12;
110
127
  switch (status) {
111
128
  case 'done':
112
- return { icon: '✓', className: 'status-done' };
129
+ return { icon: <Check size={size} />, className: 'status-done' };
113
130
  case 'in_progress':
114
- return { icon: '●', className: 'status-in-progress' };
131
+ return { icon: <Loader size={size} />, className: 'status-in-progress' };
115
132
  case 'blocked':
116
- return { icon: '⚠', className: 'status-blocked' };
133
+ return { icon: <AlertTriangle size={size} />, className: 'status-blocked' };
117
134
  case 'backlog':
118
135
  default:
119
- return { icon: '○', className: 'status-backlog' };
136
+ return { icon: <Circle size={size} />, className: 'status-backlog' };
120
137
  }
121
138
  }
122
139
 
@@ -157,6 +174,22 @@ function ContextIndicator({
157
174
  );
158
175
  }
159
176
 
177
+ /**
178
+ * Priority dot component - small color-coded circle
179
+ */
180
+ function PriorityDot({ priority, storyId }: { priority?: string | null; storyId: string }): React.ReactElement | null {
181
+ if (!priority) return null;
182
+ const colorClass = priority === 'P0' ? 'priority-p0' : priority === 'P1' ? 'priority-p1' : 'priority-p2';
183
+ return (
184
+ <span
185
+ className={`priority-dot ${colorClass}`}
186
+ data-testid={`story-priority-${storyId}`}
187
+ data-priority={priority}
188
+ title={priority}
189
+ />
190
+ );
191
+ }
192
+
160
193
  /**
161
194
  * Status badge component for stories
162
195
  */
@@ -182,12 +215,16 @@ function JiraLink({ jiraKey, storyId }: { jiraKey: string; storyId: string }): R
182
215
  const handleClick = (e: React.MouseEvent) => {
183
216
  e.preventDefault();
184
217
  const url = getJiraUrl(jiraKey);
185
- // Use electronAPI if available, otherwise open in new tab
186
- if (typeof window !== 'undefined' && (window as any).electronAPI?.shell?.openExternal) {
187
- (window as any).electronAPI.shell.openExternal(url);
188
- } else {
189
- window.open(url, '_blank');
218
+ try {
219
+ const api = (window as any).electronAPI;
220
+ if (api?.shell?.openExternal) {
221
+ api.shell.openExternal(url);
222
+ return;
223
+ }
224
+ } catch {
225
+ // electronAPI not available or call failed
190
226
  }
227
+ window.open(url, '_blank');
191
228
  };
192
229
 
193
230
  return (
@@ -213,12 +250,14 @@ export function EnhancedSprintPanel(): React.ReactElement {
213
250
  const [confirmArchive, setConfirmArchive] = useState<string | null>(null);
214
251
  const [actionError, setActionError] = useState<Error | null>(null);
215
252
 
216
- // Expand all epics by default when data first loads
253
+ // Expand all epics by default when data first loads (once only)
254
+ const hasInitializedExpansion = useRef(false);
217
255
  useEffect(() => {
218
- if (data?.epics && expandedEpics.size === 0) {
256
+ if (data?.epics && !hasInitializedExpansion.current) {
257
+ hasInitializedExpansion.current = true;
219
258
  setExpandedEpics(new Set(data.epics.map((e) => e.id)));
220
259
  }
221
- }, [data?.epics, expandedEpics.size]);
260
+ }, [data?.epics]);
222
261
 
223
262
  // Toggle epic expansion
224
263
  const toggleEpic = useCallback((epicId: string) => {
@@ -464,6 +503,7 @@ export function EnhancedSprintPanel(): React.ReactElement {
464
503
  {epic.stories.map((story) => {
465
504
  const hasContext = story.hasContext ?? false;
466
505
  const isBlocked = story.status === 'blocked';
506
+ const assigneeDisplay = formatAssignee(story.assignedTo);
467
507
  return (
468
508
  <div
469
509
  key={story.id}
@@ -473,9 +513,38 @@ export function EnhancedSprintPanel(): React.ReactElement {
473
513
  data-story-id={story.id}
474
514
  aria-label={`${story.id}: ${story.title}`}
475
515
  >
516
+ <PriorityDot priority={story.priority} storyId={story.id} />
476
517
  <StatusBadge status={story.status} storyId={story.id} />
477
518
  {story.jiraKey && <JiraLink jiraKey={story.jiraKey} storyId={story.id} />}
478
- <span className="story-title">{story.title}</span>
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>
479
548
  <ContextIndicator hasContext={hasContext} testIdPrefix="story" id={story.id} />
480
549
  <span
481
550
  className="story-points"
@@ -18,7 +18,6 @@ export { DebugPanel } from './DebugPanel';
18
18
  export { SettingsPanel } from './SettingsPanel';
19
19
  export { AuditLogPanel } from './AuditLogPanel';
20
20
  export { TTYPanel } from './TTYPanel';
21
- export { HotspotsPanel } from './HotspotsPanel';
22
21
 
23
22
  // Legacy exports - kept for backwards compatibility and tests
24
23
  export { AcceptanceCriteriaPanel, ConnectedAcceptanceCriteriaPanel } from './AcceptanceCriteriaPanel';
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
36
36
  <DialogPrimitive.Content
37
37
  ref={ref}
38
38
  className={cn(
39
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--border)] bg-card text-card-foreground p-6 shadow-2xl shadow-black/40 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
40
  className
41
41
  )}
42
42
  {...props}
@@ -86,7 +86,7 @@ const DialogTitle = React.forwardRef<
86
86
  <DialogPrimitive.Title
87
87
  ref={ref}
88
88
  className={cn(
89
- "text-lg font-semibold leading-none tracking-tight",
89
+ "text-base font-medium leading-none tracking-tight",
90
90
  className
91
91
  )}
92
92
  {...props}
@@ -100,7 +100,7 @@ const DialogDescription = React.forwardRef<
100
100
  >(({ className, ...props }, ref) => (
101
101
  <DialogPrimitive.Description
102
102
  ref={ref}
103
- className={cn("text-sm text-muted-foreground", className)}
103
+ className={cn("text-sm text-[var(--text-secondary)]", className)}
104
104
  {...props}
105
105
  />
106
106
  ))
@@ -108,18 +108,12 @@
108
108
 
109
109
  /* =============================================================================
110
110
  Utility Classes for Theme Colors
111
- ============================================================================= */
112
-
113
- /* Backgrounds */
114
- .bg-primary { background-color: var(--bg-primary); }
115
- .bg-secondary { background-color: var(--bg-secondary); }
116
- .bg-tertiary { background-color: var(--bg-tertiary); }
117
111
 
118
- /* Text */
119
- .text-primary { color: var(--text-primary); }
120
- .text-secondary { color: var(--text-secondary); }
121
- .text-muted { color: var(--text-muted); }
122
- .text-accent { color: var(--accent); }
112
+ NOTE: .bg-primary, .text-secondary, .text-muted etc. are generated by
113
+ Tailwind from tailwind.config.js. Do NOT duplicate them here — specificity
114
+ conflicts cause unpredictable results. Only define classes that Tailwind
115
+ does NOT generate (status colors, border-default, border-focus).
116
+ ============================================================================= */
123
117
 
124
118
  /* Status */
125
119
  .text-success { color: var(--status-success); }
@@ -43,3 +43,7 @@ export type { UseMarkdownParserResult } from './useMarkdownParser';
43
43
 
44
44
  export { useSyntaxHighlighter } from './useSyntaxHighlighter';
45
45
  export type { UseSyntaxHighlighterResult } from './useSyntaxHighlighter';
46
+
47
+ // Agent load analysis
48
+ export { useAgentLoad } from './useAgentLoad';
49
+ export type { AgentLoadData, AgentLoadEntry, PruneResult } from './useAgentLoad';
@@ -0,0 +1,105 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+
3
+ export interface AgentLoadComponent {
4
+ name: string;
5
+ tokens: number;
6
+ source?: string | null;
7
+ }
8
+
9
+ export interface AgentLoadEntry {
10
+ agent: string;
11
+ totalTokens: number | null;
12
+ tokenCounts?: Record<string, number>;
13
+ components?: AgentLoadComponent[];
14
+ error?: string;
15
+ }
16
+
17
+ export interface AgentLoadData {
18
+ agents: AgentLoadEntry[];
19
+ cachedAt: string;
20
+ totalAcrossAllAgents: number;
21
+ }
22
+
23
+ export interface PruneResult {
24
+ success: boolean;
25
+ tokensFreed?: number;
26
+ agent?: string;
27
+ file?: string;
28
+ error?: string;
29
+ }
30
+
31
+ export interface UseAgentLoadReturn {
32
+ data: AgentLoadData | null;
33
+ isLoading: boolean;
34
+ error: Error | null;
35
+ refresh: () => void;
36
+ pruneSidecar: (agent: string, file: string) => Promise<void>;
37
+ pruneResult: PruneResult | null;
38
+ }
39
+
40
+ export function useAgentLoad(): UseAgentLoadReturn {
41
+ const [data, setData] = useState<AgentLoadData | null>(null);
42
+ const [isLoading, setIsLoading] = useState(false);
43
+ const [error, setError] = useState<Error | null>(null);
44
+ const [pruneResult, setPruneResult] = useState<PruneResult | null>(null);
45
+ const abortRef = useRef<AbortController | null>(null);
46
+
47
+ const refresh = useCallback(() => {
48
+ if (abortRef.current) {
49
+ abortRef.current.abort();
50
+ }
51
+
52
+ const controller = new AbortController();
53
+ abortRef.current = controller;
54
+
55
+ setIsLoading(true);
56
+ setError(null);
57
+
58
+ fetch('/api/agent-load', { signal: controller.signal })
59
+ .then((res) => {
60
+ if (!res.ok) {
61
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
62
+ }
63
+ return res.json();
64
+ })
65
+ .then((json: AgentLoadData) => {
66
+ setData(json);
67
+ setIsLoading(false);
68
+ })
69
+ .catch((err) => {
70
+ if (err.name === 'AbortError') return;
71
+ setError(err instanceof Error ? err : new Error(String(err)));
72
+ setIsLoading(false);
73
+ });
74
+ }, []);
75
+
76
+ const pruneSidecar = useCallback(async (agent: string, file: string) => {
77
+ const res = await fetch('/api/agent-load/prune-sidecar', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ agent, file }),
81
+ });
82
+
83
+ if (!res.ok) {
84
+ setPruneResult({ success: false, error: `HTTP ${res.status}: ${res.statusText}` });
85
+ return;
86
+ }
87
+
88
+ const result: PruneResult = await res.json();
89
+ setPruneResult(result);
90
+
91
+ if (result.success) {
92
+ refresh();
93
+ }
94
+ }, [refresh]);
95
+
96
+ useEffect(() => {
97
+ return () => {
98
+ if (abortRef.current) {
99
+ abortRef.current.abort();
100
+ }
101
+ };
102
+ }, []);
103
+
104
+ return { data, isLoading, error, refresh, pruneSidecar, pruneResult };
105
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * useCodeMarkers React hook — Story 80-3 (MSSCI-14456)
3
+ *
4
+ * Wraps /api/code-markers endpoint with AbortController,
5
+ * loading/error/data state management, and manual refresh.
6
+ */
7
+ import { useState, useCallback, useEffect, useRef } from 'react';
8
+
9
+ export interface CodeMarker {
10
+ path: string;
11
+ line: number;
12
+ marker_type: string;
13
+ text: string;
14
+ author: string;
15
+ date: string;
16
+ age_days: number;
17
+ is_stale: boolean;
18
+ }
19
+
20
+ export interface MarkerSummary {
21
+ total_markers: number;
22
+ stale_markers: number;
23
+ by_type: Record<string, number>;
24
+ }
25
+
26
+ export interface CodeMarkersData {
27
+ success: boolean;
28
+ repo_name: string;
29
+ repo_path: string;
30
+ stale_threshold_days: number;
31
+ markers: CodeMarker[];
32
+ summary: MarkerSummary;
33
+ error: string | null;
34
+ }
35
+
36
+ export interface UseCodeMarkersOptions {
37
+ days: number;
38
+ repo?: string;
39
+ type?: 'all' | 'stale' | 'deprecated';
40
+ }
41
+
42
+ export interface UseCodeMarkersReturn {
43
+ data: CodeMarkersData | null;
44
+ isLoading: boolean;
45
+ error: Error | null;
46
+ refresh: () => void;
47
+ }
48
+
49
+ export function useCodeMarkers(options: UseCodeMarkersOptions): UseCodeMarkersReturn {
50
+ const [data, setData] = useState<CodeMarkersData | null>(null);
51
+ const [isLoading, setIsLoading] = useState(false);
52
+ const [error, setError] = useState<Error | null>(null);
53
+ const abortRef = useRef<AbortController | null>(null);
54
+
55
+ const fetchCodeMarkers = useCallback(() => {
56
+ if (abortRef.current) {
57
+ abortRef.current.abort();
58
+ }
59
+
60
+ const controller = new AbortController();
61
+ abortRef.current = controller;
62
+
63
+ setIsLoading(true);
64
+ setError(null);
65
+
66
+ const params = new URLSearchParams({ days: String(options.days) });
67
+ if (options.repo) {
68
+ params.set('repo', options.repo);
69
+ }
70
+ if (options.type) {
71
+ params.set('type', options.type);
72
+ }
73
+
74
+ fetch(`/api/code-markers?${params}`, { signal: controller.signal })
75
+ .then((res) => {
76
+ if (!res.ok) {
77
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
78
+ }
79
+ return res.json();
80
+ })
81
+ .then((json: CodeMarkersData) => {
82
+ setData(json);
83
+ setIsLoading(false);
84
+ })
85
+ .catch((err) => {
86
+ if (err.name === 'AbortError') return;
87
+ setError(err instanceof Error ? err : new Error(String(err)));
88
+ setIsLoading(false);
89
+ });
90
+ }, [options.days, options.repo, options.type]);
91
+
92
+ useEffect(() => {
93
+ return () => {
94
+ if (abortRef.current) {
95
+ abortRef.current.abort();
96
+ }
97
+ };
98
+ }, []);
99
+
100
+ return { data, isLoading, error, refresh: fetchCodeMarkers };
101
+ }
@@ -1,26 +1,41 @@
1
1
  /**
2
2
  * useColorScheme Hook
3
3
  *
4
- * Tracks the user's preferred color scheme (light/dark) via
5
- * the prefers-color-scheme media query.
4
+ * Tracks the active color scheme (light/dark) from the applied color preset's
5
+ * data-variant attribute on the document root. Falls back to OS preference.
6
6
  */
7
7
 
8
8
  import { useState, useEffect } from 'react';
9
9
 
10
10
  export type ColorScheme = 'light' | 'dark';
11
11
 
12
+ function getVariant(): ColorScheme {
13
+ const variant = document.documentElement.getAttribute('data-variant');
14
+ if (variant === 'light' || variant === 'dark') return variant;
15
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
16
+ }
17
+
12
18
  export function useColorScheme(): ColorScheme {
13
- const [scheme, setScheme] = useState<ColorScheme>(() =>
14
- window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
15
- );
19
+ const [scheme, setScheme] = useState<ColorScheme>(getVariant);
16
20
 
17
21
  useEffect(() => {
18
- const mq = window.matchMedia('(prefers-color-scheme: dark)');
19
- const handler = (e: MediaQueryListEvent) => {
20
- setScheme(e.matches ? 'dark' : 'light');
22
+ // Watch for preset changes via data-variant attribute
23
+ const observer = new MutationObserver(() => {
24
+ setScheme(getVariant());
25
+ });
26
+ observer.observe(document.documentElement, {
27
+ attributes: true,
28
+ attributeFilter: ['data-variant'],
29
+ });
30
+
31
+ // Also listen to presetChange events from applyPreset()
32
+ const handlePreset = () => setScheme(getVariant());
33
+ window.addEventListener('presetChange', handlePreset);
34
+
35
+ return () => {
36
+ observer.disconnect();
37
+ window.removeEventListener('presetChange', handlePreset);
21
38
  };
22
- mq.addEventListener('change', handler);
23
- return () => mq.removeEventListener('change', handler);
24
39
  }, []);
25
40
 
26
41
  return scheme;