@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.
- package/dist/api/hook-request.d.ts +11 -0
- package/dist/api/hook-request.d.ts.map +1 -1
- package/dist/api/hook-request.js +126 -28
- package/dist/api/hook-request.js.map +1 -1
- package/dist/api/hotspots.d.ts +3 -0
- package/dist/api/hotspots.d.ts.map +1 -0
- package/dist/api/hotspots.js +54 -0
- package/dist/api/hotspots.js.map +1 -0
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +3 -0
- package/dist/api/index.js.map +1 -1
- package/dist/api/permissions.d.ts +16 -0
- package/dist/api/permissions.d.ts.map +1 -0
- package/dist/api/permissions.js +67 -0
- package/dist/api/permissions.js.map +1 -0
- package/dist/api/settings.d.ts +1 -1
- package/dist/api/settings.d.ts.map +1 -1
- package/dist/api/settings.js +44 -17
- package/dist/api/settings.js.map +1 -1
- package/dist/api/theme-agents.d.ts +4 -0
- package/dist/api/theme-agents.d.ts.map +1 -1
- package/dist/api/theme-agents.js +3 -0
- package/dist/api/theme-agents.js.map +1 -1
- package/dist/approval-gate.d.ts +3 -75
- package/dist/approval-gate.d.ts.map +1 -1
- package/dist/approval-gate.js +4 -121
- package/dist/approval-gate.js.map +1 -1
- package/dist/hooks/cyclist-pretooluse-hook.d.ts +60 -0
- package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +1 -0
- package/dist/hooks/cyclist-pretooluse-hook.js +57 -0
- package/dist/hooks/cyclist-pretooluse-hook.js.map +1 -0
- package/dist/hooks/pretooluse-hook.d.ts +89 -0
- package/dist/hooks/pretooluse-hook.d.ts.map +1 -0
- package/dist/hooks/pretooluse-hook.js +235 -0
- package/dist/hooks/pretooluse-hook.js.map +1 -0
- package/dist/main.d.ts +1 -134
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +42 -373
- package/dist/main.js.map +1 -1
- package/dist/menu-builder.d.ts +7 -1
- package/dist/menu-builder.d.ts.map +1 -1
- package/dist/menu-builder.js +36 -1
- package/dist/menu-builder.js.map +1 -1
- package/dist/otlp-receiver.d.ts.map +1 -1
- package/dist/otlp-receiver.js +6 -0
- package/dist/otlp-receiver.js.map +1 -1
- package/dist/public/css/react.css +1 -1
- package/dist/public/js/react/react.js +42 -42
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +16 -3
- package/dist/server.js.map +1 -1
- package/dist/settings-store.d.ts +3 -1
- package/dist/settings-store.d.ts.map +1 -1
- package/dist/settings-store.js +18 -9
- package/dist/settings-store.js.map +1 -1
- package/dist/story-parser.d.ts +17 -0
- package/dist/story-parser.d.ts.map +1 -1
- package/dist/story-parser.js +183 -13
- package/dist/story-parser.js.map +1 -1
- package/dist/websocket.d.ts +1 -0
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +48 -5
- package/dist/websocket.js.map +1 -1
- package/dist/workflow-presets.d.ts +72 -0
- package/dist/workflow-presets.d.ts.map +1 -0
- package/dist/workflow-presets.js +93 -0
- package/dist/workflow-presets.js.map +1 -0
- package/package.json +2 -2
- package/src/public/App.tsx +61 -1
- package/src/public/components/ApprovalModal/index.tsx +31 -1
- package/src/public/components/ControlBar.tsx +19 -20
- package/src/public/components/DockviewWorkspace.tsx +39 -5
- package/src/public/components/FontPicker/index.tsx +118 -33
- package/src/public/components/FullFileTree.tsx +223 -0
- package/src/public/components/Message.tsx +89 -11
- package/src/public/components/MessageView.tsx +206 -93
- package/src/public/components/PersonaHeader.tsx +47 -15
- package/src/public/components/SubagentSpan.tsx +15 -8
- package/src/public/components/panels/BackgroundPanel.tsx +1 -1
- package/src/public/components/panels/ChangedPanel.tsx +30 -44
- package/src/public/components/panels/HotspotsPanel.tsx +365 -0
- package/src/public/components/panels/MessagePanel.tsx +79 -5
- package/src/public/components/panels/SettingsPanel.tsx +3 -28
- package/src/public/components/panels/WorkflowPanel.tsx +108 -13
- package/src/public/components/panels/index.ts +1 -0
- package/src/public/contexts/ClaudeContext.tsx +16 -1
- package/src/public/css/theme-system.css +46 -38
- package/src/public/hooks/useColorScheme.ts +27 -0
- package/src/public/hooks/useFileBrowser.ts +71 -0
- package/src/public/hooks/useHotspots.ts +113 -0
- package/src/public/hooks/usePlanModeExit.ts +105 -0
- package/src/public/hooks/useStory.ts +12 -3
- package/src/public/images/cyclist-dark.png +0 -0
- package/src/public/images/cyclist-light.png +0 -0
- package/src/public/styles/dockview-theme.css +31 -33
- package/src/public/styles/tailwind.css +417 -58
- package/src/public/types/message.ts +6 -1
- package/src/public/utils/markdown.ts +2 -2
- package/src/public/utils/slash-commands.ts +1 -1
- package/src/public/utils/toolStackGrouper.ts +5 -6
|
@@ -1,77 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ChangedPanel -
|
|
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
|
|
10
|
-
import { 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
|
-
|
|
22
|
-
if (indexStatus === '
|
|
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
|
-
//
|
|
49
|
-
const
|
|
50
|
-
const
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
46
|
+
return map;
|
|
66
47
|
}, [repos]);
|
|
67
48
|
|
|
68
|
-
const handleFileClick = useCallback((
|
|
69
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
const PROTECTED_PANELS = new Set(
|
|
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">
|