@pennyfarthing/cyclist 9.3.0 → 10.0.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 (101) hide show
  1. package/dist/api/hook-request.d.ts +11 -0
  2. package/dist/api/hook-request.d.ts.map +1 -1
  3. package/dist/api/hook-request.js +126 -28
  4. package/dist/api/hook-request.js.map +1 -1
  5. package/dist/api/hotspots.d.ts +3 -0
  6. package/dist/api/hotspots.d.ts.map +1 -0
  7. package/dist/api/hotspots.js +54 -0
  8. package/dist/api/hotspots.js.map +1 -0
  9. package/dist/api/index.d.ts +2 -0
  10. package/dist/api/index.d.ts.map +1 -1
  11. package/dist/api/index.js +3 -0
  12. package/dist/api/index.js.map +1 -1
  13. package/dist/api/permissions.d.ts +16 -0
  14. package/dist/api/permissions.d.ts.map +1 -0
  15. package/dist/api/permissions.js +67 -0
  16. package/dist/api/permissions.js.map +1 -0
  17. package/dist/api/settings.d.ts +1 -1
  18. package/dist/api/settings.d.ts.map +1 -1
  19. package/dist/api/settings.js +44 -17
  20. package/dist/api/settings.js.map +1 -1
  21. package/dist/api/theme-agents.d.ts +4 -0
  22. package/dist/api/theme-agents.d.ts.map +1 -1
  23. package/dist/api/theme-agents.js +3 -0
  24. package/dist/api/theme-agents.js.map +1 -1
  25. package/dist/approval-gate.d.ts +3 -75
  26. package/dist/approval-gate.d.ts.map +1 -1
  27. package/dist/approval-gate.js +4 -121
  28. package/dist/approval-gate.js.map +1 -1
  29. package/dist/hooks/cyclist-pretooluse-hook.d.ts +60 -0
  30. package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +1 -0
  31. package/dist/hooks/cyclist-pretooluse-hook.js +57 -0
  32. package/dist/hooks/cyclist-pretooluse-hook.js.map +1 -0
  33. package/dist/hooks/pretooluse-hook.d.ts +89 -0
  34. package/dist/hooks/pretooluse-hook.d.ts.map +1 -0
  35. package/dist/hooks/pretooluse-hook.js +235 -0
  36. package/dist/hooks/pretooluse-hook.js.map +1 -0
  37. package/dist/main.d.ts +1 -134
  38. package/dist/main.d.ts.map +1 -1
  39. package/dist/main.js +42 -373
  40. package/dist/main.js.map +1 -1
  41. package/dist/menu-builder.d.ts +7 -1
  42. package/dist/menu-builder.d.ts.map +1 -1
  43. package/dist/menu-builder.js +36 -1
  44. package/dist/menu-builder.js.map +1 -1
  45. package/dist/otlp-receiver.d.ts.map +1 -1
  46. package/dist/otlp-receiver.js +6 -0
  47. package/dist/otlp-receiver.js.map +1 -1
  48. package/dist/public/css/react.css +1 -1
  49. package/dist/public/js/react/react.js +42 -42
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/server.js +16 -3
  52. package/dist/server.js.map +1 -1
  53. package/dist/settings-store.d.ts +3 -1
  54. package/dist/settings-store.d.ts.map +1 -1
  55. package/dist/settings-store.js +18 -9
  56. package/dist/settings-store.js.map +1 -1
  57. package/dist/story-parser.d.ts +17 -0
  58. package/dist/story-parser.d.ts.map +1 -1
  59. package/dist/story-parser.js +183 -13
  60. package/dist/story-parser.js.map +1 -1
  61. package/dist/websocket.d.ts +1 -0
  62. package/dist/websocket.d.ts.map +1 -1
  63. package/dist/websocket.js +48 -5
  64. package/dist/websocket.js.map +1 -1
  65. package/dist/workflow-presets.d.ts +72 -0
  66. package/dist/workflow-presets.d.ts.map +1 -0
  67. package/dist/workflow-presets.js +93 -0
  68. package/dist/workflow-presets.js.map +1 -0
  69. package/package.json +2 -2
  70. package/src/public/App.tsx +61 -1
  71. package/src/public/components/ApprovalModal/index.tsx +31 -1
  72. package/src/public/components/ControlBar.tsx +19 -20
  73. package/src/public/components/DockviewWorkspace.tsx +39 -5
  74. package/src/public/components/FontPicker/index.tsx +118 -33
  75. package/src/public/components/FullFileTree.tsx +223 -0
  76. package/src/public/components/Message.tsx +89 -11
  77. package/src/public/components/MessageView.tsx +206 -93
  78. package/src/public/components/PersonaHeader.tsx +47 -15
  79. package/src/public/components/SubagentSpan.tsx +15 -8
  80. package/src/public/components/panels/BackgroundPanel.tsx +1 -1
  81. package/src/public/components/panels/ChangedPanel.tsx +30 -44
  82. package/src/public/components/panels/HotspotsPanel.tsx +365 -0
  83. package/src/public/components/panels/MessagePanel.tsx +79 -5
  84. package/src/public/components/panels/SettingsPanel.tsx +3 -28
  85. package/src/public/components/panels/WorkflowPanel.tsx +108 -13
  86. package/src/public/components/panels/index.ts +1 -0
  87. package/src/public/contexts/ClaudeContext.tsx +16 -1
  88. package/src/public/css/theme-system.css +46 -38
  89. package/src/public/hooks/useColorScheme.ts +27 -0
  90. package/src/public/hooks/useFileBrowser.ts +71 -0
  91. package/src/public/hooks/useHotspots.ts +113 -0
  92. package/src/public/hooks/usePlanModeExit.ts +105 -0
  93. package/src/public/hooks/useStory.ts +12 -3
  94. package/src/public/images/cyclist-dark.png +0 -0
  95. package/src/public/images/cyclist-light.png +0 -0
  96. package/src/public/styles/dockview-theme.css +31 -33
  97. package/src/public/styles/tailwind.css +417 -58
  98. package/src/public/types/message.ts +6 -1
  99. package/src/public/utils/markdown.ts +2 -2
  100. package/src/public/utils/slash-commands.ts +1 -1
  101. package/src/public/utils/toolStackGrouper.ts +5 -6
@@ -3,16 +3,20 @@
3
3
  *
4
4
  * Extracted from ProgressPanel as part of MSSCI-14188.
5
5
  * Shows workflow type badge (TDD/BDD/Trivial) and phase progress.
6
+ * MSSCI-14300: Added stepped workflow "Step N of M" display.
7
+ * MSSCI-14301: Added available workflows discovery list.
6
8
  *
7
9
  * Story: MSSCI-14188 - Split Progress panel into Workflow, AC, and Todo panels
8
10
  * Epic: epic-76 (Dockview Panel Migration)
9
11
  */
10
12
 
11
- import React from 'react';
13
+ import React, { useCallback } from 'react';
12
14
  import { Badge } from '@/components/ui/badge';
15
+ import { Button } from '@/components/ui/button';
13
16
  import { Skeleton } from '@/components/ui/skeleton';
17
+ import { useClaudeContext } from '../../contexts/ClaudeContext';
14
18
  import { useStory } from '../../hooks/useStory';
15
- import type { WorkflowPhase } from '../../../story-parser.js';
19
+ import type { WorkflowPhase, AvailableWorkflow } from '../../../story-parser.js';
16
20
 
17
21
  // =============================================================================
18
22
  // Helper Functions
@@ -42,7 +46,7 @@ function formatWorkflowType(type: string | null): string {
42
46
  }
43
47
 
44
48
  // =============================================================================
45
- // Phase Step Component
49
+ // Phase Step Component (for phased workflows)
46
50
  // =============================================================================
47
51
 
48
52
  function PhaseStep({ phase, isLast }: { phase: WorkflowPhase; isLast: boolean }): React.ReactElement {
@@ -60,12 +64,90 @@ function PhaseStep({ phase, isLast }: { phase: WorkflowPhase; isLast: boolean })
60
64
  );
61
65
  }
62
66
 
67
+ // =============================================================================
68
+ // Stepped Progress Component (for stepped workflows)
69
+ // =============================================================================
70
+
71
+ function SteppedProgress({ phases }: { phases: WorkflowPhase[] }): React.ReactElement {
72
+ const total = phases.length;
73
+ const currentIndex = phases.findIndex(p => p.status === 'current');
74
+ const doneCount = phases.filter(p => p.status === 'done').length;
75
+
76
+ // If all done, current step = total; otherwise use 1-based index of current
77
+ const currentStep = currentIndex >= 0 ? currentIndex + 1 : (doneCount === total ? total : 1);
78
+ const currentPhase = currentIndex >= 0 ? phases[currentIndex] : null;
79
+
80
+ return (
81
+ <div className="stepped-progress">
82
+ <span className="stepped-counter">Step {currentStep} of {total}</span>
83
+ {currentPhase && (
84
+ <span className="stepped-current-label">{currentPhase.label}</span>
85
+ )}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // =============================================================================
91
+ // Available Workflows List (MSSCI-14301)
92
+ // =============================================================================
93
+
94
+ function AvailableWorkflowsList({ workflows, onStart }: {
95
+ workflows: AvailableWorkflow[];
96
+ onStart?: (workflow: AvailableWorkflow) => void;
97
+ }): React.ReactElement {
98
+ return (
99
+ <div className="available-workflows">
100
+ <div className="available-workflows-header">
101
+ <span className="available-workflows-title">Available Workflows ({workflows.length})</span>
102
+ </div>
103
+ <div className="available-workflows-list">
104
+ {workflows.map((wf) => (
105
+ <div
106
+ key={wf.name}
107
+ className="workflow-entry"
108
+ data-testid="workflow-entry"
109
+ data-workflow-entry-type={wf.type}
110
+ >
111
+ <div className="workflow-entry-header">
112
+ <span className="workflow-entry-name">{wf.name}</span>
113
+ <Badge variant="outline" className="workflow-entry-type-badge">
114
+ {wf.type}
115
+ </Badge>
116
+ </div>
117
+ <div className="workflow-entry-description">{wf.description}</div>
118
+ {wf.type === 'stepped' && (
119
+ <div className="workflow-entry-hint">/workflow start {wf.name}</div>
120
+ )}
121
+ <div className="workflow-entry-footer">
122
+ <Button
123
+ variant="ghost"
124
+ size="sm"
125
+ className="workflow-start-button"
126
+ data-testid="workflow-start-button"
127
+ onClick={() => onStart?.(wf)}
128
+ >
129
+ Start
130
+ </Button>
131
+ </div>
132
+ </div>
133
+ ))}
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
63
139
  // =============================================================================
64
140
  // WorkflowPanel Component
65
141
  // =============================================================================
66
142
 
67
143
  export function WorkflowPanel(): React.ReactElement {
68
- const { story, isLoading, error } = useStory();
144
+ const { story, isLoading, error, availableWorkflows } = useStory();
145
+ const { send, isConnected } = useClaudeContext();
146
+
147
+ const handleStartWorkflow = useCallback((wf: AvailableWorkflow) => {
148
+ if (!isConnected) return;
149
+ send(`/workflow start ${wf.name}`);
150
+ }, [send, isConnected]);
69
151
 
70
152
  if (isLoading) {
71
153
  return (
@@ -92,8 +174,17 @@ export function WorkflowPanel(): React.ReactElement {
92
174
 
93
175
  const workflowType = story?.workflow ?? null;
94
176
  const phases = story?.workflowPhases ?? null;
177
+ const isStepped = story?.workflowType === 'stepped';
95
178
 
96
179
  if (!workflowType && (!phases || phases.length === 0)) {
180
+ // MSSCI-14301: Show available workflows when no active workflow
181
+ if (availableWorkflows && availableWorkflows.length > 0) {
182
+ return (
183
+ <div className="workflow-panel" data-testid="workflow-panel">
184
+ <AvailableWorkflowsList workflows={availableWorkflows} onStart={handleStartWorkflow} />
185
+ </div>
186
+ );
187
+ }
97
188
  return (
98
189
  <div className="workflow-panel" data-testid="workflow-panel">
99
190
  <div className="placeholder">No active workflow</div>
@@ -111,15 +202,19 @@ export function WorkflowPanel(): React.ReactElement {
111
202
  </Badge>
112
203
 
113
204
  {phases && phases.length > 0 && (
114
- <div className="phase-progress">
115
- {phases.map((phase, index) => (
116
- <PhaseStep
117
- key={phase.name}
118
- phase={phase}
119
- isLast={index === phases.length - 1}
120
- />
121
- ))}
122
- </div>
205
+ isStepped ? (
206
+ <SteppedProgress phases={phases} />
207
+ ) : (
208
+ <div className="phase-progress">
209
+ {phases.map((phase, index) => (
210
+ <PhaseStep
211
+ key={phase.name}
212
+ phase={phase}
213
+ isLast={index === phases.length - 1}
214
+ />
215
+ ))}
216
+ </div>
217
+ )
123
218
  )}
124
219
  </div>
125
220
  </div>
@@ -18,6 +18,7 @@ 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';
21
22
 
22
23
  // Legacy exports - kept for backwards compatibility and tests
23
24
  export { AcceptanceCriteriaPanel, ConnectedAcceptanceCriteriaPanel } from './AcceptanceCriteriaPanel';
@@ -24,6 +24,7 @@ interface WebSocketClaudeMessage {
24
24
  type MessageCallback = (message: ClaudeMessage) => void;
25
25
  type CompleteCallback = () => void;
26
26
  type ErrorCallback = (error: string) => void;
27
+ type ClearCallback = () => void;
27
28
 
28
29
  /** User message sent via send() - for display in MessagePanel */
29
30
  interface UserMessageData {
@@ -56,6 +57,8 @@ interface ClaudeContextValue {
56
57
  onError: (callback: ErrorCallback) => () => void;
57
58
  /** Subscribe to user messages sent via send() - for display in MessagePanel */
58
59
  onUserMessage: (callback: UserMessageCallback) => () => void;
60
+ /** Subscribe to clear/reset events */
61
+ onClear: (callback: ClearCallback) => () => void;
59
62
  }
60
63
 
61
64
  // =============================================================================
@@ -83,6 +86,7 @@ export function ClaudeProvider({ children }: ClaudeProviderProps): React.ReactEl
83
86
  const completeCallbacksRef = useRef<Set<CompleteCallback>>(new Set());
84
87
  const errorCallbacksRef = useRef<Set<ErrorCallback>>(new Set());
85
88
  const userMessageCallbacksRef = useRef<Set<UserMessageCallback>>(new Set());
89
+ const clearCallbacksRef = useRef<Set<ClearCallback>>(new Set());
86
90
 
87
91
  // Connect to WebSocket
88
92
  const connect = useCallback(() => {
@@ -204,6 +208,7 @@ export function ClaudeProvider({ children }: ClaudeProviderProps): React.ReactEl
204
208
  }
205
209
 
206
210
  wsRef.current.send(JSON.stringify({ type: 'clear' }));
211
+ clearCallbacksRef.current.forEach(cb => cb());
207
212
  }, []);
208
213
 
209
214
  // Clear session and reload agent (TirePump)
@@ -215,6 +220,7 @@ export function ClaudeProvider({ children }: ClaudeProviderProps): React.ReactEl
215
220
 
216
221
  console.log('[ClaudeContext] TirePump: clearAndReload agent:', agent);
217
222
  wsRef.current.send(JSON.stringify({ type: 'clearAndReload', agent }));
223
+ clearCallbacksRef.current.forEach(cb => cb());
218
224
  }, []);
219
225
 
220
226
  // Set permission mode
@@ -260,6 +266,14 @@ export function ClaudeProvider({ children }: ClaudeProviderProps): React.ReactEl
260
266
  };
261
267
  }, []);
262
268
 
269
+ // Subscribe to clear/reset events
270
+ const onClear = useCallback((callback: ClearCallback) => {
271
+ clearCallbacksRef.current.add(callback);
272
+ return () => {
273
+ clearCallbacksRef.current.delete(callback);
274
+ };
275
+ }, []);
276
+
263
277
  const value = useMemo(() => ({
264
278
  send,
265
279
  abort,
@@ -272,7 +286,8 @@ export function ClaudeProvider({ children }: ClaudeProviderProps): React.ReactEl
272
286
  onComplete,
273
287
  onError,
274
288
  onUserMessage,
275
- }), [send, abort, clear, clearAndReload, setMode, isConnected, mode, onMessage, onComplete, onError, onUserMessage]);
289
+ onClear,
290
+ }), [send, abort, clear, clearAndReload, setMode, isConnected, mode, onMessage, onComplete, onError, onUserMessage, onClear]);
276
291
 
277
292
  return (
278
293
  <ClaudeContext.Provider value={value}>
@@ -152,26 +152,31 @@
152
152
  Tool Call Block Styling (MSSCI-13402) - Klinger Redesign
153
153
  ============================================================================= */
154
154
 
155
- /* Base tool call block */
155
+ /* Base tool call block — Tufte: indented, no border box */
156
156
  .tool-call-block {
157
- background-color: var(--bg-primary);
158
- border-radius: 6px;
159
- border: 1px solid var(--border);
157
+ background: none;
158
+ border-radius: 0;
159
+ border: none;
160
+ border-left: 2px solid var(--border);
160
161
  overflow: hidden;
162
+ margin-left: 2.75rem; /* align with message content (avatar + gap) */
163
+ margin-top: 0.5rem;
164
+ margin-bottom: 0.5rem;
165
+ padding-left: 0.5rem;
161
166
  transition: border-color 0.15s ease;
162
167
  }
163
168
 
164
169
  .tool-call-block:hover {
165
- border-color: var(--accent);
170
+ border-left-color: var(--accent);
166
171
  }
167
172
 
168
- /* Tool Header - the main bar */
173
+ /* Tool Header - compact single-line */
169
174
  .tool-header {
170
175
  display: flex;
171
176
  align-items: center;
172
- gap: 0.625rem;
173
- padding: 0.5rem 0.75rem;
174
- background-color: var(--bg-secondary);
177
+ gap: 0.5rem;
178
+ padding: 0.125rem 0;
179
+ background: none;
175
180
  }
176
181
 
177
182
  /* Tool Type Badge - colored pill for instant recognition */
@@ -201,14 +206,14 @@
201
206
  .tool-call-block.tool-edit .tool-type-badge { background-color: var(--tool-edit-color); }
202
207
  .tool-call-block.tool-task .tool-type-badge { background-color: var(--tool-task-color); }
203
208
 
204
- /* Keep left border for additional visual cue */
205
- .tool-call-block.tool-read { border-left: 3px solid var(--tool-read-color); }
206
- .tool-call-block.tool-write { border-left: 3px solid var(--tool-write-color); }
207
- .tool-call-block.tool-bash { border-left: 3px solid var(--tool-bash-color); }
208
- .tool-call-block.tool-glob { border-left: 3px solid var(--tool-glob-color); }
209
- .tool-call-block.tool-grep { border-left: 3px solid var(--tool-grep-color); }
210
- .tool-call-block.tool-edit { border-left: 3px solid var(--tool-edit-color); }
211
- .tool-call-block.tool-task { border-left: 3px solid var(--tool-task-color); }
209
+ /* Color the left border by tool type */
210
+ .tool-call-block.tool-read { border-left-color: var(--tool-read-color); }
211
+ .tool-call-block.tool-write { border-left-color: var(--tool-write-color); }
212
+ .tool-call-block.tool-bash { border-left-color: var(--tool-bash-color); }
213
+ .tool-call-block.tool-glob { border-left-color: var(--tool-glob-color); }
214
+ .tool-call-block.tool-grep { border-left-color: var(--tool-grep-color); }
215
+ .tool-call-block.tool-edit { border-left-color: var(--tool-edit-color); }
216
+ .tool-call-block.tool-task { border-left-color: var(--tool-task-color); }
212
217
 
213
218
  /* Intent summary - the human-readable description */
214
219
  .tool-name {
@@ -238,8 +243,7 @@
238
243
 
239
244
  /* Error state styling */
240
245
  .tool-call-block.tool-error {
241
- border-left-color: var(--status-error);
242
- background-color: rgba(239, 68, 68, 0.05);
246
+ border-left-color: var(--status-error) !important;
243
247
  }
244
248
 
245
249
  .tool-call-block.tool-error .tool-type-badge {
@@ -251,9 +255,8 @@
251
255
  display: flex;
252
256
  align-items: center;
253
257
  gap: 0.5rem;
254
- padding: 0.375rem 0.75rem;
255
- background-color: var(--bg-tertiary);
256
- border-top: 1px solid var(--border);
258
+ padding: 0.125rem 0;
259
+ background: none;
257
260
  }
258
261
 
259
262
  .tool-result-toggle {
@@ -391,10 +394,13 @@
391
394
 
392
395
  /* Container for grouped tool calls */
393
396
  .tool-stack {
394
- margin: 0.75rem 0;
395
- border-radius: 8px;
396
- background-color: var(--bg-secondary);
397
- border: 1px solid var(--border);
397
+ margin: 0.5rem 0;
398
+ margin-left: 2.75rem; /* align with message content */
399
+ border-radius: 0;
400
+ background: none;
401
+ border: none;
402
+ border-left: 2px solid var(--border);
403
+ padding-left: 0.5rem;
398
404
  }
399
405
 
400
406
  .tool-stack.collapsed {
@@ -405,21 +411,21 @@
405
411
  .tool-stack-header {
406
412
  display: flex;
407
413
  align-items: center;
408
- gap: 0.75rem;
409
- padding: 0.625rem 0.875rem;
414
+ gap: 0.5rem;
415
+ padding: 0.25rem 0;
410
416
  cursor: pointer;
411
417
  user-select: none;
412
- background-color: var(--bg-secondary);
413
- border-bottom: 1px solid transparent;
414
- transition: background-color 0.15s ease, border-color 0.15s ease;
418
+ background: none;
419
+ border-bottom: none;
420
+ transition: background-color 0.15s ease;
415
421
  }
416
422
 
417
423
  .tool-stack:not(.collapsed) .tool-stack-header {
418
- border-bottom-color: var(--border);
424
+ border-bottom-color: transparent;
419
425
  }
420
426
 
421
427
  .tool-stack-header:hover {
422
- background-color: var(--bg-tertiary);
428
+ background-color: var(--bg-secondary);
423
429
  }
424
430
 
425
431
  .tool-stack-header:focus {
@@ -515,14 +521,16 @@
515
521
  .tool-stack-content {
516
522
  display: flex;
517
523
  flex-direction: column;
518
- gap: 0.5rem;
519
- padding: 0.75rem;
520
- background-color: var(--bg-tertiary);
524
+ gap: 0.125rem;
525
+ padding: 0.25rem 0;
526
+ background: none;
521
527
  }
522
528
 
523
- /* Visual distinction for tools within a stack */
529
+ /* Within a stack, tool blocks don't need extra indent or left border */
524
530
  .tool-stack-content .tool-call-block {
525
- margin: 0;
531
+ margin-left: 0;
532
+ border-left: none;
533
+ padding-left: 0;
526
534
  }
527
535
 
528
536
  /* Tool state classes within stack */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * useColorScheme Hook
3
+ *
4
+ * Tracks the user's preferred color scheme (light/dark) via
5
+ * the prefers-color-scheme media query.
6
+ */
7
+
8
+ import { useState, useEffect } from 'react';
9
+
10
+ export type ColorScheme = 'light' | 'dark';
11
+
12
+ export function useColorScheme(): ColorScheme {
13
+ const [scheme, setScheme] = useState<ColorScheme>(() =>
14
+ window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
15
+ );
16
+
17
+ useEffect(() => {
18
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
19
+ const handler = (e: MediaQueryListEvent) => {
20
+ setScheme(e.matches ? 'dark' : 'light');
21
+ };
22
+ mq.addEventListener('change', handler);
23
+ return () => mq.removeEventListener('change', handler);
24
+ }, []);
25
+
26
+ return scheme;
27
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * useFileBrowser Hook
3
+ *
4
+ * Fetches directory listings from /api/files for the full file tree.
5
+ * Lazy-loads subdirectories on demand.
6
+ */
7
+
8
+ import { useState, useCallback } from 'react';
9
+
10
+ export interface DirectoryEntry {
11
+ name: string;
12
+ path: string;
13
+ type: 'file' | 'directory';
14
+ isModified?: boolean;
15
+ size?: number;
16
+ }
17
+
18
+ export interface DirectoryListing {
19
+ path: string;
20
+ entries: DirectoryEntry[];
21
+ }
22
+
23
+ interface DirectoryCache {
24
+ [path: string]: DirectoryEntry[];
25
+ }
26
+
27
+ interface UseFileBrowserResult {
28
+ cache: DirectoryCache;
29
+ loading: Set<string>;
30
+ error: string | null;
31
+ fetchDirectory: (dirPath: string) => Promise<void>;
32
+ }
33
+
34
+ export function useFileBrowser(): UseFileBrowserResult {
35
+ const [cache, setCache] = useState<DirectoryCache>({});
36
+ const [loading, setLoading] = useState<Set<string>>(new Set());
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ const fetchDirectory = useCallback(async (dirPath: string) => {
40
+ // Already cached or loading
41
+ if (cache[dirPath] || loading.has(dirPath)) return;
42
+
43
+ setLoading(prev => new Set(prev).add(dirPath));
44
+ setError(null);
45
+
46
+ try {
47
+ const params = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '';
48
+ const res = await fetch(`/api/files${params}`);
49
+ if (!res.ok) throw new Error(`Failed to list directory: ${res.statusText}`);
50
+ const listing: DirectoryListing = await res.json();
51
+
52
+ // Sort: directories first, then files, alphabetical within each
53
+ const sorted = listing.entries.sort((a, b) => {
54
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
55
+ return a.name.localeCompare(b.name);
56
+ });
57
+
58
+ setCache(prev => ({ ...prev, [dirPath || '__root__']: sorted }));
59
+ } catch (err) {
60
+ setError(err instanceof Error ? err.message : 'Failed to load directory');
61
+ } finally {
62
+ setLoading(prev => {
63
+ const next = new Set(prev);
64
+ next.delete(dirPath);
65
+ return next;
66
+ });
67
+ }
68
+ }, [cache, loading]);
69
+
70
+ return { cache, loading, error, fetchDirectory };
71
+ }
@@ -0,0 +1,113 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+
3
+ // Types matching Python HotspotResult / MultiRepoHotspotResult
4
+ export interface FileHotspot {
5
+ path: string;
6
+ change_count: number;
7
+ bug_fix_count: number;
8
+ author_count: number;
9
+ lines_added: number;
10
+ lines_deleted: number;
11
+ churn: number;
12
+ last_changed: string;
13
+ hotspot_score: number;
14
+ }
15
+
16
+ export interface DirectoryHotspot {
17
+ path: string;
18
+ file_count: number;
19
+ total_changes: number;
20
+ total_bug_fixes: number;
21
+ avg_author_count: number;
22
+ hotspot_score: number;
23
+ }
24
+
25
+ export interface HotspotRepoResult {
26
+ success: boolean;
27
+ repo_name: string;
28
+ repo_path: string;
29
+ time_window_days: number;
30
+ commit_count: number;
31
+ file_hotspots: FileHotspot[];
32
+ directory_hotspots: DirectoryHotspot[];
33
+ error?: string;
34
+ }
35
+
36
+ export interface HotspotData {
37
+ success: boolean;
38
+ // Single-repo result fields (when --path is used)
39
+ repo_name?: string;
40
+ repo_path?: string;
41
+ time_window_days?: number;
42
+ commit_count?: number;
43
+ file_hotspots?: FileHotspot[];
44
+ directory_hotspots?: DirectoryHotspot[];
45
+ // Multi-repo result fields
46
+ repo_results?: HotspotRepoResult[];
47
+ error?: string;
48
+ }
49
+
50
+ export interface UseHotspotsOptions {
51
+ days: number;
52
+ repo?: string;
53
+ }
54
+
55
+ export interface UseHotspotsReturn {
56
+ data: HotspotData | null;
57
+ isLoading: boolean;
58
+ error: Error | null;
59
+ refresh: () => void;
60
+ }
61
+
62
+ export function useHotspots(options: UseHotspotsOptions): UseHotspotsReturn {
63
+ const [data, setData] = useState<HotspotData | null>(null);
64
+ const [isLoading, setIsLoading] = useState(false);
65
+ const [error, setError] = useState<Error | null>(null);
66
+ const abortRef = useRef<AbortController | null>(null);
67
+
68
+ const fetchHotspots = useCallback(() => {
69
+ // Cancel any in-flight request
70
+ if (abortRef.current) {
71
+ abortRef.current.abort();
72
+ }
73
+
74
+ const controller = new AbortController();
75
+ abortRef.current = controller;
76
+
77
+ setIsLoading(true);
78
+ setError(null);
79
+
80
+ const params = new URLSearchParams({ days: String(options.days) });
81
+ if (options.repo) {
82
+ params.set('repo', options.repo);
83
+ }
84
+
85
+ fetch(`/api/hotspots?${params}`, { signal: controller.signal })
86
+ .then((res) => {
87
+ if (!res.ok) {
88
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
89
+ }
90
+ return res.json();
91
+ })
92
+ .then((json: HotspotData) => {
93
+ setData(json);
94
+ setIsLoading(false);
95
+ })
96
+ .catch((err) => {
97
+ if (err.name === 'AbortError') return;
98
+ setError(err instanceof Error ? err : new Error(String(err)));
99
+ setIsLoading(false);
100
+ });
101
+ }, [options.days, options.repo]);
102
+
103
+ // Cleanup abort controller on unmount
104
+ useEffect(() => {
105
+ return () => {
106
+ if (abortRef.current) {
107
+ abortRef.current.abort();
108
+ }
109
+ };
110
+ }, []);
111
+
112
+ return { data, isLoading, error, refresh: fetchHotspots };
113
+ }