@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
@@ -1,77 +1,63 @@
1
1
  /**
2
- * ChangedPanel - Display changed files (FileTree wrapper)
2
+ * ChangedPanel - Full file tree with changed file highlighting
3
3
  *
4
4
  * Story MSSCI-12717 - React Migration
5
5
  * ADR-0020 - Changed to use git as source of truth instead of /ws/diffs
6
+ *
7
+ * Shows full project directory tree with changed files highlighted.
8
+ * Uses /api/files for tree structure and /ws/git for change status.
6
9
  */
7
10
 
8
11
  import React, { useCallback, useMemo } from 'react';
9
- import FileTree, { FileChange, FileStatus } from '../FileTree';
10
- import { useGitStatus, DirtyFile } from '../../hooks/useGitStatus';
12
+ import { FullFileTree } from '../FullFileTree';
13
+ import { useGitStatus } from '../../hooks/useGitStatus';
14
+ import type { FileStatus } from '../FileTree';
15
+ import type { DirectoryEntry } from '../../hooks/useFileBrowser';
11
16
 
12
17
  /**
13
18
  * Map git status code to FileStatus
14
- * Git porcelain format: XY where X=index, Y=worktree
15
- * ?: untracked, A: added, M: modified, D: deleted, R: renamed, C: copied
16
19
  */
17
20
  function gitStatusToFileStatus(gitStatus: string): FileStatus {
18
21
  const indexStatus = gitStatus[0] || ' ';
19
22
  const workTreeStatus = gitStatus[1] || ' ';
20
23
 
21
- // Deleted in either index or worktree
22
- if (indexStatus === 'D' || workTreeStatus === 'D') {
23
- return 'deleted';
24
- }
25
-
26
- // Untracked or newly added
27
- if (indexStatus === '?' || indexStatus === 'A') {
28
- return 'created';
29
- }
30
-
31
- // Everything else is modified (M, R, C, etc.)
24
+ if (indexStatus === 'D' || workTreeStatus === 'D') return 'deleted';
25
+ if (indexStatus === '?' || indexStatus === 'A') return 'created';
32
26
  return 'modified';
33
27
  }
34
28
 
35
- /**
36
- * Convert DirtyFile array to FileChange array
37
- */
38
- function dirtyFilesToFileChanges(dirtyFiles: DirtyFile[]): FileChange[] {
39
- return dirtyFiles.map(file => ({
40
- path: file.path,
41
- status: gitStatusToFileStatus(file.status),
42
- }));
43
- }
44
-
45
29
  export function ChangedPanel(): React.ReactElement {
46
30
  const { repos } = useGitStatus();
47
31
 
48
- // Flatten all dirty files from all repos into FileChange array
49
- const files = useMemo(() => {
50
- const allFiles: FileChange[] = [];
32
+ // Build a Map<filePath, FileStatus> from all dirty files
33
+ const changedFiles = useMemo(() => {
34
+ const map = new Map<string, FileStatus>();
51
35
  for (const repo of repos) {
52
- const repoFiles = dirtyFilesToFileChanges(repo.files);
53
- // Prefix with repo name if multiple repos
54
- if (repos.length > 1) {
55
- for (const file of repoFiles) {
56
- allFiles.push({
57
- ...file,
58
- path: `${repo.name}/${file.path}`,
59
- });
60
- }
61
- } else {
62
- allFiles.push(...repoFiles);
36
+ for (const file of repo.files) {
37
+ // Use full path from repo for matching against /api/files paths
38
+ const fullPath = repos.length > 1
39
+ ? `${repo.path}/${file.path}`
40
+ : `${repo.path}/${file.path}`;
41
+ map.set(fullPath, gitStatusToFileStatus(file.status));
42
+ // Also store relative path for fallback matching
43
+ map.set(file.path, gitStatusToFileStatus(file.status));
63
44
  }
64
45
  }
65
- return allFiles;
46
+ return map;
66
47
  }, [repos]);
67
48
 
68
- const handleFileClick = useCallback((file: FileChange) => {
69
- console.log('[ChangedPanel] File clicked:', file.path);
49
+ const handleFileClick = useCallback((entry: DirectoryEntry, status?: FileStatus) => {
50
+ // Open file in editor via API
51
+ fetch('/api/files/edit', {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ path: entry.path }),
55
+ }).catch(err => console.error('[ChangedPanel] Failed to open file:', err));
70
56
  }, []);
71
57
 
72
58
  return (
73
59
  <div className="changed-panel" data-testid="changed-panel">
74
- <FileTree files={files} onFileClick={handleFileClick} />
60
+ <FullFileTree changedFiles={changedFiles} onFileClick={handleFileClick} />
75
61
  </div>
76
62
  );
77
63
  }
@@ -0,0 +1,365 @@
1
+ /**
2
+ * HotspotsPanel - Git history hotspot detector
3
+ *
4
+ * Shows files and directories with highest change frequency, bug fix concentration,
5
+ * and multi-author churn. Sortable table with time window controls.
6
+ */
7
+
8
+ import React, { useState, useMemo, useCallback } from 'react';
9
+ import { Button } from '@/components/ui/button';
10
+ import { Badge } from '@/components/ui/badge';
11
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
12
+ import { Skeleton } from '@/components/ui/skeleton';
13
+ import { useHotspots, FileHotspot, DirectoryHotspot, HotspotRepoResult } from '../../hooks/useHotspots';
14
+
15
+ type SortField = 'hotspot_score' | 'change_count' | 'bug_fix_count' | 'author_count' | 'churn' | 'path';
16
+ type SortDirection = 'asc' | 'desc';
17
+ type ViewMode = 'files' | 'dirs';
18
+
19
+ const TIME_WINDOWS = [30, 60, 90] as const;
20
+
21
+ function SortableHeader({
22
+ label,
23
+ field,
24
+ currentSort,
25
+ currentDirection,
26
+ onSort,
27
+ align = 'right',
28
+ }: {
29
+ label: string;
30
+ field: SortField;
31
+ currentSort: SortField;
32
+ currentDirection: SortDirection;
33
+ onSort: (field: SortField) => void;
34
+ align?: 'left' | 'right';
35
+ }) {
36
+ const isActive = currentSort === field;
37
+ const arrow = isActive ? (currentDirection === 'desc' ? ' v' : ' ^') : '';
38
+
39
+ return (
40
+ <th
41
+ className={`hotspots-th ${align === 'left' ? 'text-left' : 'text-right'} ${isActive ? 'active' : ''}`}
42
+ onClick={() => onSort(field)}
43
+ role="columnheader"
44
+ aria-sort={isActive ? (currentDirection === 'desc' ? 'descending' : 'ascending') : 'none'}
45
+ style={{ cursor: 'pointer', userSelect: 'none' }}
46
+ >
47
+ {label}{arrow}
48
+ </th>
49
+ );
50
+ }
51
+
52
+ function FileTable({
53
+ hotspots,
54
+ sortField,
55
+ sortDirection,
56
+ onSort,
57
+ }: {
58
+ hotspots: FileHotspot[];
59
+ sortField: SortField;
60
+ sortDirection: SortDirection;
61
+ onSort: (field: SortField) => void;
62
+ }) {
63
+ const sorted = useMemo(() => {
64
+ const items = [...hotspots];
65
+ items.sort((a, b) => {
66
+ const aVal = a[sortField as keyof FileHotspot];
67
+ const bVal = b[sortField as keyof FileHotspot];
68
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
69
+ return sortDirection === 'desc' ? bVal - aVal : aVal - bVal;
70
+ }
71
+ const aStr = String(aVal);
72
+ const bStr = String(bVal);
73
+ return sortDirection === 'desc' ? bStr.localeCompare(aStr) : aStr.localeCompare(bStr);
74
+ });
75
+ return items;
76
+ }, [hotspots, sortField, sortDirection]);
77
+
78
+ return (
79
+ <table className="hotspots-table" role="table">
80
+ <thead>
81
+ <tr>
82
+ <SortableHeader label="Score" field="hotspot_score" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
83
+ <SortableHeader label="Changes" field="change_count" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
84
+ <SortableHeader label="Fixes" field="bug_fix_count" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
85
+ <SortableHeader label="Authors" field="author_count" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
86
+ <SortableHeader label="Churn" field="churn" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
87
+ <SortableHeader label="File" field="path" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} align="left" />
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {sorted.map((h) => (
92
+ <tr key={h.path}>
93
+ <td className="text-right">
94
+ <Badge variant={h.hotspot_score >= 50 ? 'destructive' : h.hotspot_score >= 25 ? 'outline' : 'secondary'}>
95
+ {h.hotspot_score.toFixed(1)}
96
+ </Badge>
97
+ </td>
98
+ <td className="text-right">{h.change_count}</td>
99
+ <td className="text-right">{h.bug_fix_count}</td>
100
+ <td className="text-right">{h.author_count}</td>
101
+ <td className="text-right">{h.churn}</td>
102
+ <td className="text-left">
103
+ <Tooltip>
104
+ <TooltipTrigger asChild>
105
+ <span className="hotspots-filepath">{h.path}</span>
106
+ </TooltipTrigger>
107
+ <TooltipContent>{h.path}</TooltipContent>
108
+ </Tooltip>
109
+ </td>
110
+ </tr>
111
+ ))}
112
+ </tbody>
113
+ </table>
114
+ );
115
+ }
116
+
117
+ function DirTable({
118
+ hotspots,
119
+ sortField,
120
+ sortDirection,
121
+ onSort,
122
+ }: {
123
+ hotspots: DirectoryHotspot[];
124
+ sortField: SortField;
125
+ sortDirection: SortDirection;
126
+ onSort: (field: SortField) => void;
127
+ }) {
128
+ const sorted = useMemo(() => {
129
+ const items = [...hotspots];
130
+ items.sort((a, b) => {
131
+ // Map file-centric fields to directory equivalents
132
+ const fieldMap: Record<string, keyof DirectoryHotspot> = {
133
+ change_count: 'total_changes',
134
+ bug_fix_count: 'total_bug_fixes',
135
+ author_count: 'avg_author_count',
136
+ churn: 'file_count',
137
+ };
138
+ const key = (fieldMap[sortField] || sortField) as keyof DirectoryHotspot;
139
+ const aVal = a[key];
140
+ const bVal = b[key];
141
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
142
+ return sortDirection === 'desc' ? bVal - aVal : aVal - bVal;
143
+ }
144
+ return sortDirection === 'desc'
145
+ ? String(bVal).localeCompare(String(aVal))
146
+ : String(aVal).localeCompare(String(bVal));
147
+ });
148
+ return items;
149
+ }, [hotspots, sortField, sortDirection]);
150
+
151
+ return (
152
+ <table className="hotspots-table" role="table">
153
+ <thead>
154
+ <tr>
155
+ <SortableHeader label="Score" field="hotspot_score" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
156
+ <SortableHeader label="Changes" field="change_count" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
157
+ <SortableHeader label="Fixes" field="bug_fix_count" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
158
+ <SortableHeader label="Authors" field="author_count" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
159
+ <SortableHeader label="Files" field="churn" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} />
160
+ <SortableHeader label="Directory" field="path" currentSort={sortField} currentDirection={sortDirection} onSort={onSort} align="left" />
161
+ </tr>
162
+ </thead>
163
+ <tbody>
164
+ {sorted.map((d) => (
165
+ <tr key={d.path}>
166
+ <td className="text-right">
167
+ <Badge variant={d.hotspot_score >= 50 ? 'destructive' : d.hotspot_score >= 25 ? 'outline' : 'secondary'}>
168
+ {d.hotspot_score.toFixed(1)}
169
+ </Badge>
170
+ </td>
171
+ <td className="text-right">{d.total_changes}</td>
172
+ <td className="text-right">{d.total_bug_fixes}</td>
173
+ <td className="text-right">{d.avg_author_count.toFixed(1)}</td>
174
+ <td className="text-right">{d.file_count}</td>
175
+ <td className="text-left">
176
+ <Tooltip>
177
+ <TooltipTrigger asChild>
178
+ <span className="hotspots-filepath">{d.path}</span>
179
+ </TooltipTrigger>
180
+ <TooltipContent>{d.path}</TooltipContent>
181
+ </Tooltip>
182
+ </td>
183
+ </tr>
184
+ ))}
185
+ </tbody>
186
+ </table>
187
+ );
188
+ }
189
+
190
+ export function HotspotsPanel(): React.ReactElement {
191
+ const [days, setDays] = useState<number>(90);
192
+ const [viewMode, setViewMode] = useState<ViewMode>('files');
193
+ const [sortField, setSortField] = useState<SortField>('hotspot_score');
194
+ const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
195
+
196
+ const { data, isLoading, error, refresh } = useHotspots({ days });
197
+
198
+ const handleSort = useCallback((field: SortField) => {
199
+ setSortField((prev) => {
200
+ if (prev === field) {
201
+ setSortDirection((d) => (d === 'desc' ? 'asc' : 'desc'));
202
+ return prev;
203
+ }
204
+ setSortDirection('desc');
205
+ return field;
206
+ });
207
+ }, []);
208
+
209
+ // Flatten results — single-repo returns fields directly, multi-repo has repo_results
210
+ const repoResults: HotspotRepoResult[] = useMemo(() => {
211
+ if (!data) return [];
212
+ if (data.repo_results) return data.repo_results;
213
+ // Single-repo result — wrap in array
214
+ if (data.file_hotspots) {
215
+ return [{
216
+ success: data.success,
217
+ repo_name: data.repo_name || '',
218
+ repo_path: data.repo_path || '',
219
+ time_window_days: data.time_window_days || days,
220
+ commit_count: data.commit_count || 0,
221
+ file_hotspots: data.file_hotspots || [],
222
+ directory_hotspots: data.directory_hotspots || [],
223
+ }];
224
+ }
225
+ return [];
226
+ }, [data, days]);
227
+
228
+ // Merge all file/dir hotspots across repos
229
+ const allFiles = useMemo(() => {
230
+ const files: FileHotspot[] = [];
231
+ for (const r of repoResults) {
232
+ if (r.success) files.push(...r.file_hotspots);
233
+ }
234
+ return files;
235
+ }, [repoResults]);
236
+
237
+ const allDirs = useMemo(() => {
238
+ const dirs: DirectoryHotspot[] = [];
239
+ for (const r of repoResults) {
240
+ if (r.success) dirs.push(...r.directory_hotspots);
241
+ }
242
+ return dirs;
243
+ }, [repoResults]);
244
+
245
+ const totalCommits = repoResults.reduce((sum, r) => sum + (r.commit_count || 0), 0);
246
+
247
+ // Loading state
248
+ if (isLoading) {
249
+ return (
250
+ <div className="hotspots-panel loading" data-testid="hotspots-panel">
251
+ <div className="space-y-3 p-2">
252
+ <Skeleton className="h-4 w-40" />
253
+ <Skeleton className="h-3 w-full" />
254
+ <Skeleton className="h-3 w-full" />
255
+ <Skeleton className="h-3 w-3/4" />
256
+ <Skeleton className="h-3 w-full" />
257
+ <Skeleton className="h-3 w-5/6" />
258
+ </div>
259
+ </div>
260
+ );
261
+ }
262
+
263
+ // Error state
264
+ if (error) {
265
+ return (
266
+ <div className="hotspots-panel error" data-testid="hotspots-panel">
267
+ <div className="error-message">{error.message}</div>
268
+ <Button variant="outline" size="sm" onClick={refresh}>Retry</Button>
269
+ </div>
270
+ );
271
+ }
272
+
273
+ return (
274
+ <TooltipProvider delayDuration={300}>
275
+ <div className="hotspots-panel" data-testid="hotspots-panel">
276
+ {/* Controls bar */}
277
+ <div className="hotspots-controls">
278
+ <div className="hotspots-time-windows">
279
+ {TIME_WINDOWS.map((w) => (
280
+ <Button
281
+ key={w}
282
+ variant={days === w ? 'default' : 'outline'}
283
+ size="sm"
284
+ onClick={() => setDays(w)}
285
+ >
286
+ {w}d
287
+ </Button>
288
+ ))}
289
+ </div>
290
+
291
+ <div className="hotspots-view-toggle">
292
+ <Button
293
+ variant={viewMode === 'files' ? 'default' : 'outline'}
294
+ size="sm"
295
+ onClick={() => setViewMode('files')}
296
+ >
297
+ Files
298
+ </Button>
299
+ <Button
300
+ variant={viewMode === 'dirs' ? 'default' : 'outline'}
301
+ size="sm"
302
+ onClick={() => setViewMode('dirs')}
303
+ >
304
+ Dirs
305
+ </Button>
306
+ </div>
307
+
308
+ <Tooltip>
309
+ <TooltipTrigger asChild>
310
+ <Button variant="outline" size="sm" onClick={refresh}>
311
+ Analyze
312
+ </Button>
313
+ </TooltipTrigger>
314
+ <TooltipContent>Run hotspot analysis</TooltipContent>
315
+ </Tooltip>
316
+ </div>
317
+
318
+ {/* Summary */}
319
+ {data && (
320
+ <div className="hotspots-summary">
321
+ <span>{totalCommits} commits</span>
322
+ <span>{allFiles.length} files</span>
323
+ <span>{allDirs.length} dirs</span>
324
+ </div>
325
+ )}
326
+
327
+ {/* No data state */}
328
+ {!data && (
329
+ <div className="hotspots-empty">
330
+ <p>Click <strong>Analyze</strong> to detect code hotspots</p>
331
+ </div>
332
+ )}
333
+
334
+ {/* Table */}
335
+ {data && viewMode === 'files' && (
336
+ allFiles.length > 0 ? (
337
+ <FileTable
338
+ hotspots={allFiles.slice(0, 50)}
339
+ sortField={sortField}
340
+ sortDirection={sortDirection}
341
+ onSort={handleSort}
342
+ />
343
+ ) : (
344
+ <div className="hotspots-empty">No file hotspots found</div>
345
+ )
346
+ )}
347
+
348
+ {data && viewMode === 'dirs' && (
349
+ allDirs.length > 0 ? (
350
+ <DirTable
351
+ hotspots={allDirs.slice(0, 50)}
352
+ sortField={sortField}
353
+ sortDirection={sortDirection}
354
+ onSort={handleSort}
355
+ />
356
+ ) : (
357
+ <div className="hotspots-empty">No directory hotspots found</div>
358
+ )
359
+ )}
360
+ </div>
361
+ </TooltipProvider>
362
+ );
363
+ }
364
+
365
+ export default HotspotsPanel;
@@ -15,6 +15,7 @@ import PersonaHeader from '../PersonaHeader';
15
15
  import StatsStrip from '../StatsStrip';
16
16
  import { useMessageQueueContext, QueuedMessage, InjectDependencies } from '../../contexts/MessageQueueContext';
17
17
  import { useClaudeContext } from '../../contexts/ClaudeContext';
18
+ import { usePersona } from '../../hooks/usePersona';
18
19
  import type { ClaudeMessage } from '../../hooks/useClaude';
19
20
  import type { MessageData } from '../../types/message';
20
21
 
@@ -31,7 +32,14 @@ interface SDKToolUseBlock {
31
32
  input?: Record<string, unknown>;
32
33
  }
33
34
 
34
- type SDKContentBlock = SDKTextBlock | SDKToolUseBlock | { type: string; text?: string };
35
+ interface SDKToolResultBlock {
36
+ type: 'tool_result';
37
+ tool_use_id?: string;
38
+ content?: string;
39
+ is_error?: boolean;
40
+ }
41
+
42
+ type SDKContentBlock = SDKTextBlock | SDKToolUseBlock | SDKToolResultBlock | { type: string; text?: string };
35
43
 
36
44
  interface SDKMessage {
37
45
  type: string;
@@ -140,7 +148,7 @@ function transformMessage(sdkMessage: SDKMessage): MessageData[] {
140
148
  return results;
141
149
  }
142
150
 
143
- // Handle user messages
151
+ // Handle user messages (may contain tool_result blocks for completed Task tools)
144
152
  if (sdkMessage.type === 'user') {
145
153
  let content = '';
146
154
  const contentArray = sdkMessage.message?.content || sdkMessage.content;
@@ -151,6 +159,23 @@ function transformMessage(sdkMessage: SDKMessage): MessageData[] {
151
159
  )
152
160
  .map(block => block.text)
153
161
  .join('');
162
+
163
+ // MSSCI-14394: Extract tool_result blocks from user messages.
164
+ // The SDK delivers tool results inside user-type messages with tool_use_id
165
+ // matching the original tool_use's tool_id. Without extracting these,
166
+ // the subagent cleanup code never fires and spans accumulate forever.
167
+ const toolResultBlocks = contentArray.filter(
168
+ (block): block is SDKToolResultBlock => block.type === 'tool_result'
169
+ );
170
+ for (const resultBlock of toolResultBlocks) {
171
+ results.push({
172
+ type: 'tool_result',
173
+ tool_id: resultBlock.tool_use_id,
174
+ content: typeof resultBlock.content === 'string' ? resultBlock.content : '',
175
+ timestamp,
176
+ is_error: resultBlock.is_error,
177
+ });
178
+ }
154
179
  } else if (typeof contentArray === 'string') {
155
180
  content = contentArray;
156
181
  }
@@ -215,15 +240,23 @@ export function MessagePanel(): React.ReactElement {
215
240
  isStopping,
216
241
  bellMode,
217
242
  relayMode,
243
+ contextPercent,
244
+ currentAgent,
218
245
  handleStop,
219
246
  handleForceStop,
220
247
  handleReset,
221
248
  handleBellModeChange,
222
249
  handleRelayModeChange,
250
+ handleTirePump,
223
251
  } = useControlBar();
224
252
 
225
253
  // Claude context for WebSocket communication
226
- const { send, abort, onMessage, onComplete, onError, onUserMessage, isConnected } = useClaudeContext();
254
+ const { send, abort, onMessage, onComplete, onError, onUserMessage, onClear, isConnected } = useClaudeContext();
255
+
256
+ // Persona context - capture current persona to stamp on agent messages
257
+ const { persona } = usePersona();
258
+ const personaRef = useRef(persona);
259
+ personaRef.current = persona;
227
260
 
228
261
  // Message queue context for turn complete handling and bell mode (shared with Editor)
229
262
  const { handleTurnComplete, pauseQueue, onBellConsumed, injectMessage } = useMessageQueueContext();
@@ -256,11 +289,36 @@ export function MessagePanel(): React.ReactElement {
256
289
  },
257
290
  };
258
291
 
259
- // Handle incoming SDK message
292
+ // Handle incoming SDK message - stamp current persona on agent messages
260
293
  const handleSDKMessage = useCallback((sdkMessage: ClaudeMessage) => {
261
294
  const transformed = transformMessage(sdkMessage as SDKMessage);
262
295
  if (transformed.length > 0) {
263
- setMessages(prev => [...prev, ...transformed]);
296
+ const p = personaRef.current;
297
+ const stamped = transformed.map(msg =>
298
+ msg.type === 'agent' && p
299
+ ? { ...msg, agentSlug: p.slug ?? undefined, agentTheme: p.theme ?? undefined, agentCharacter: p.character ?? undefined }
300
+ : msg
301
+ );
302
+
303
+ // MSSCI-14394: When tool_results arrive for completed Task tools, remove their
304
+ // subagent messages from the view (they have parent_id matching the tool_result's tool_id).
305
+ const completedTaskIds = stamped
306
+ .filter(m => m.type === 'tool_result' && !m.parent_id && m.tool_id)
307
+ .map(m => m.tool_id!);
308
+
309
+ if (completedTaskIds.length > 0) {
310
+ setMessages(prev => {
311
+ const idsToRemove = new Set(completedTaskIds.filter(id =>
312
+ prev.some(m => m.parent_id === id)
313
+ ));
314
+ if (idsToRemove.size > 0) {
315
+ return [...prev.filter(m => !m.parent_id || !idsToRemove.has(m.parent_id)), ...stamped];
316
+ }
317
+ return [...prev, ...stamped];
318
+ });
319
+ } else {
320
+ setMessages(prev => [...prev, ...stamped]);
321
+ }
264
322
  }
265
323
  }, []);
266
324
 
@@ -305,6 +363,19 @@ export function MessagePanel(): React.ReactElement {
305
363
  return cleanup;
306
364
  }, [onUserMessage]);
307
365
 
366
+ // Subscribe to clear events — insert a divider message
367
+ useEffect(() => {
368
+ const cleanup = onClear(() => {
369
+ setMessages(prev => [...prev, {
370
+ type: 'context_cleared',
371
+ content: 'Context cleared',
372
+ timestamp: Date.now(),
373
+ }]);
374
+ setIsProcessing(false);
375
+ });
376
+ return cleanup;
377
+ }, [onClear]);
378
+
308
379
  // Connect to Claude events via WebSocket context
309
380
  useEffect(() => {
310
381
  if (!isConnected) {
@@ -370,6 +441,9 @@ export function MessagePanel(): React.ReactElement {
370
441
  relayMode={relayMode}
371
442
  onBellModeChange={handleBellModeChange}
372
443
  onRelayModeChange={handleRelayModeChange}
444
+ contextPercent={contextPercent}
445
+ currentAgent={currentAgent}
446
+ onTirePump={handleTirePump}
373
447
  />
374
448
  </div>
375
449
  </div>
@@ -45,10 +45,6 @@ interface Settings {
45
45
  show_flow?: boolean;
46
46
  sidebar_width?: number;
47
47
  };
48
- notifications?: {
49
- phase_change?: boolean;
50
- sound?: boolean;
51
- };
52
48
  pennyfarthing?: {
53
49
  theme?: string;
54
50
  };
@@ -80,8 +76,8 @@ const PANEL_DISPLAY_NAMES: Record<string, string> = {
80
76
  settings: 'Settings',
81
77
  };
82
78
 
83
- // Panels that cannot be hidden (sacred center)
84
- const PROTECTED_PANELS = new Set(['message']);
79
+ // Panels that cannot be hidden
80
+ const PROTECTED_PANELS = new Set<string>();
85
81
 
86
82
  export function SettingsPanel(): React.ReactElement {
87
83
  const [settings, setSettings] = useState<Settings | null>(null);
@@ -147,6 +143,7 @@ export function SettingsPanel(): React.ReactElement {
147
143
 
148
144
  // Load color preset from project config
149
145
  loadPresetFromProject().then(presetId => {
146
+ applyPreset(presetId);
150
147
  setColorPreset(presetId);
151
148
  });
152
149
 
@@ -425,28 +422,6 @@ export function SettingsPanel(): React.ReactElement {
425
422
 
426
423
  <Separator className="my-2" />
427
424
 
428
- <section className="settings-section">
429
- <h4>Notifications</h4>
430
- <div className="toggle-setting">
431
- <Switch
432
- checked={settings.notifications?.phase_change || false}
433
- onCheckedChange={(checked: boolean) => handleToggle('notifications', 'phase_change', checked)}
434
- disabled={saving}
435
- />
436
- Phase change alerts
437
- </div>
438
- <div className="toggle-setting">
439
- <Switch
440
- checked={settings.notifications?.sound || false}
441
- onCheckedChange={(checked: boolean) => handleToggle('notifications', 'sound', checked)}
442
- disabled={saving}
443
- />
444
- Sound effects
445
- </div>
446
- </section>
447
-
448
- <Separator className="my-2" />
449
-
450
425
  <section className="settings-section">
451
426
  <h4>Panel Visibility</h4>
452
427
  <div className="panel-visibility-list">