@pennyfarthing/cyclist 10.0.3 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/agent-load.d.ts +3 -0
- package/dist/api/agent-load.d.ts.map +1 -0
- package/dist/api/agent-load.js +124 -0
- package/dist/api/agent-load.js.map +1 -0
- package/dist/api/code-markers.d.ts +9 -0
- package/dist/api/code-markers.d.ts.map +1 -0
- package/dist/api/code-markers.js +62 -0
- package/dist/api/code-markers.js.map +1 -0
- package/dist/api/complexity.d.ts +3 -0
- package/dist/api/complexity.d.ts.map +1 -0
- package/dist/api/complexity.js +47 -0
- package/dist/api/complexity.js.map +1 -0
- package/dist/api/dead-code.d.ts +3 -0
- package/dist/api/dead-code.d.ts.map +1 -0
- package/dist/api/dead-code.js +70 -0
- package/dist/api/dead-code.js.map +1 -0
- package/dist/api/dependencies.d.ts +3 -0
- package/dist/api/dependencies.d.ts.map +1 -0
- package/dist/api/dependencies.js +43 -0
- package/dist/api/dependencies.js.map +1 -0
- package/dist/api/git.d.ts +3 -2
- package/dist/api/git.d.ts.map +1 -1
- package/dist/api/git.js +11 -6
- package/dist/api/git.js.map +1 -1
- package/dist/api/health-score.d.ts +3 -0
- package/dist/api/health-score.d.ts.map +1 -0
- package/dist/api/health-score.js +47 -0
- package/dist/api/health-score.js.map +1 -0
- package/dist/api/hotspots.d.ts.map +1 -1
- package/dist/api/hotspots.js +9 -1
- package/dist/api/hotspots.js.map +1 -1
- package/dist/api/index.d.ts +7 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +12 -2
- package/dist/api/index.js.map +1 -1
- package/dist/api/persona.d.ts +2 -0
- package/dist/api/persona.d.ts.map +1 -1
- package/dist/api/persona.js +19 -1
- package/dist/api/persona.js.map +1 -1
- package/dist/api/settings.js +1 -1
- package/dist/api/settings.js.map +1 -1
- package/dist/claude-service.d.ts +8 -2
- package/dist/claude-service.d.ts.map +1 -1
- package/dist/claude-service.js +21 -2
- package/dist/claude-service.js.map +1 -1
- package/dist/git-diff.d.ts.map +1 -1
- package/dist/git-diff.js +6 -5
- package/dist/git-diff.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +11 -2
- package/dist/main.js.map +1 -1
- package/dist/plugin-loader.d.ts +49 -0
- package/dist/plugin-loader.d.ts.map +1 -0
- package/dist/plugin-loader.js +92 -0
- package/dist/plugin-loader.js.map +1 -0
- package/dist/preload.js +12 -1
- package/dist/preload.js.map +1 -1
- package/dist/prime.d.ts +3 -2
- package/dist/prime.d.ts.map +1 -1
- package/dist/prime.js +25 -8
- package/dist/prime.js.map +1 -1
- package/dist/public/css/react.css +1 -1
- package/dist/public/js/react/react.js +50 -39
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +19 -16
- package/dist/server.js.map +1 -1
- package/dist/sprint-data.d.ts +6 -0
- package/dist/sprint-data.d.ts.map +1 -1
- package/dist/sprint-data.js +118 -67
- package/dist/sprint-data.js.map +1 -1
- package/dist/story-parser.js +1 -1
- package/dist/story-parser.js.map +1 -1
- package/dist/theme-metadata.js +2 -2
- package/dist/theme-metadata.js.map +1 -1
- package/dist/websocket.d.ts +0 -6
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +36 -40
- package/dist/websocket.js.map +1 -1
- package/package.json +2 -1
- package/portraits/fifth-element/large/cornelius-54343.png +0 -0
- package/portraits/fifth-element/large/diva-53453.png +0 -0
- package/portraits/fifth-element/large/korben-34232.png +0 -0
- package/portraits/fifth-element/large/leeloo-54333.png +0 -0
- package/portraits/fifth-element/large/lindberg-34432.png +0 -0
- package/portraits/fifth-element/large/mondoshawan-55131.png +0 -0
- package/portraits/fifth-element/large/munro-25321.png +0 -0
- package/portraits/fifth-element/large/pacoli-45232.png +0 -0
- package/portraits/fifth-element/large/ruby-53544.png +0 -0
- package/portraits/fifth-element/large/zorg-45312.png +0 -0
- package/portraits/fifth-element/medium/cornelius-54343.png +0 -0
- package/portraits/fifth-element/medium/diva-53453.png +0 -0
- package/portraits/fifth-element/medium/korben-34232.png +0 -0
- package/portraits/fifth-element/medium/leeloo-54333.png +0 -0
- package/portraits/fifth-element/medium/lindberg-34432.png +0 -0
- package/portraits/fifth-element/medium/mondoshawan-55131.png +0 -0
- package/portraits/fifth-element/medium/munro-25321.png +0 -0
- package/portraits/fifth-element/medium/pacoli-45232.png +0 -0
- package/portraits/fifth-element/medium/ruby-53544.png +0 -0
- package/portraits/fifth-element/medium/zorg-45312.png +0 -0
- package/src/public/App.tsx +0 -2
- package/src/public/components/AgentLoadDialog.tsx +202 -0
- package/src/public/components/AgentPopup.tsx +3 -5
- package/src/public/components/ContextSparkline.tsx +56 -0
- package/src/public/components/ControlBar.tsx +140 -6
- package/src/public/components/DeadCodeDialog.tsx +169 -0
- package/src/public/components/DockviewWorkspace.tsx +0 -3
- package/src/public/components/FullFileTree.tsx +18 -4
- package/src/public/components/HealthGauge.tsx +181 -0
- package/src/public/components/MessageView.tsx +23 -6
- package/src/public/components/PersonaHeader.tsx +46 -3
- package/src/public/components/TandemPortrait.tsx +71 -0
- package/src/public/components/ToolCallBlock.tsx +21 -6
- package/src/public/components/dialogs/CodeMarkersDialog.tsx +169 -0
- package/src/public/components/dialogs/ComplexityDialog.tsx +163 -0
- package/src/public/components/dialogs/DependenciesDialog.tsx +120 -0
- package/src/public/components/dialogs/HotspotsDialog.tsx +451 -0
- package/src/public/components/dialogs/ToolDialog.tsx +43 -0
- package/src/public/components/panels/ACPanel.tsx +1 -1
- package/src/public/components/panels/AcceptanceCriteriaPanel.tsx +15 -30
- package/src/public/components/panels/DebugPanel.tsx +79 -3
- package/src/public/components/panels/GitPanel.tsx +25 -30
- package/src/public/components/panels/MessagePanel.tsx +44 -2
- package/src/public/components/panels/SettingsPanel.tsx +4 -4
- package/src/public/components/panels/SprintPanel.tsx +247 -123
- package/src/public/components/panels/index.ts +0 -1
- package/src/public/components/ui/dialog.tsx +3 -3
- package/src/public/css/theme-system.css +98 -11
- package/src/public/hooks/index.ts +4 -0
- package/src/public/hooks/useAgentLoad.ts +105 -0
- package/src/public/hooks/useCodeMarkers.ts +101 -0
- package/src/public/hooks/useColorScheme.ts +25 -10
- package/src/public/hooks/useComplexity.ts +80 -0
- package/src/public/hooks/useDeadCode.ts +99 -0
- package/src/public/hooks/useDependencies.ts +82 -0
- package/src/public/hooks/useHealthScore.ts +69 -0
- package/src/public/hooks/useHotspots.ts +11 -1
- package/src/public/hooks/usePersona.ts +26 -3
- package/src/public/hooks/useSprint.ts +7 -1
- package/src/public/styles/tailwind.css +389 -83
- package/src/public/utils/messageFilters.ts +77 -6
- package/src/public/utils/slash-commands.ts +3 -35
- package/dist/hooks/cyclist-pretooluse-hook.d.ts +0 -60
- package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +0 -1
- package/dist/hooks/cyclist-pretooluse-hook.js +0 -57
- package/dist/hooks/cyclist-pretooluse-hook.js.map +0 -1
- package/dist/hooks/pretooluse-hook.d.ts +0 -89
- package/dist/hooks/pretooluse-hook.d.ts.map +0 -1
- package/dist/hooks/pretooluse-hook.js +0 -235
- package/dist/hooks/pretooluse-hook.js.map +0 -1
- package/dist/notification-sound.d.ts +0 -59
- package/dist/notification-sound.d.ts.map +0 -1
- package/dist/notification-sound.js +0 -219
- package/dist/notification-sound.js.map +0 -1
- package/src/public/types/electron.d.ts +0 -18
|
@@ -49,7 +49,6 @@ export const PANEL_INVENTORY = {
|
|
|
49
49
|
TODO: 'todo',
|
|
50
50
|
BACKGROUND: 'background',
|
|
51
51
|
GIT: 'git',
|
|
52
|
-
HOTSPOTS: 'hotspots',
|
|
53
52
|
SETTINGS: 'settings',
|
|
54
53
|
} as const;
|
|
55
54
|
|
|
@@ -91,7 +90,6 @@ export const RIGHT_SIDEBAR_PANELS = [
|
|
|
91
90
|
PANEL_INVENTORY.TODO,
|
|
92
91
|
PANEL_INVENTORY.BACKGROUND,
|
|
93
92
|
PANEL_INVENTORY.GIT,
|
|
94
|
-
PANEL_INVENTORY.HOTSPOTS,
|
|
95
93
|
PANEL_INVENTORY.SETTINGS,
|
|
96
94
|
] as const;
|
|
97
95
|
|
|
@@ -109,7 +107,6 @@ const PANEL_TITLES: Record<string, string> = {
|
|
|
109
107
|
todo: 'Todo',
|
|
110
108
|
background: 'Subagents',
|
|
111
109
|
git: 'Git',
|
|
112
|
-
hotspots: 'Hotspots',
|
|
113
110
|
settings: 'Settings',
|
|
114
111
|
};
|
|
115
112
|
|
|
@@ -93,15 +93,29 @@ function TreeDirectoryNode({
|
|
|
93
93
|
cache: Record<string, DirectoryEntry[]>;
|
|
94
94
|
loading: Set<string>;
|
|
95
95
|
}): React.ReactElement {
|
|
96
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
97
|
-
const children = cache[entry.path];
|
|
98
|
-
const isLoading = loading.has(entry.path);
|
|
99
|
-
|
|
100
96
|
// Check if this directory contains any changed files
|
|
101
97
|
const hasChanges = Array.from(changedFiles.keys()).some(
|
|
102
98
|
(filePath) => filePath.startsWith(entry.path + '/')
|
|
103
99
|
);
|
|
104
100
|
|
|
101
|
+
const [isOpen, setIsOpen] = useState(hasChanges);
|
|
102
|
+
const children = cache[entry.path];
|
|
103
|
+
const isLoading = loading.has(entry.path);
|
|
104
|
+
|
|
105
|
+
// Auto-fetch children when directory has changes and is opened by default
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (hasChanges && !children && !loading.has(entry.path)) {
|
|
108
|
+
fetchDirectory(entry.path);
|
|
109
|
+
}
|
|
110
|
+
}, [hasChanges, children, entry.path, fetchDirectory, loading]);
|
|
111
|
+
|
|
112
|
+
// Auto-open when changes appear in this directory
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (hasChanges) {
|
|
115
|
+
setIsOpen(true);
|
|
116
|
+
}
|
|
117
|
+
}, [hasChanges]);
|
|
118
|
+
|
|
105
119
|
const handleToggle = useCallback(() => {
|
|
106
120
|
const willOpen = !isOpen;
|
|
107
121
|
setIsOpen(willOpen);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
|
|
4
|
+
export interface HealthGaugeDimension {
|
|
5
|
+
name: string;
|
|
6
|
+
score: number | null;
|
|
7
|
+
weight: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HealthGaugeProps {
|
|
11
|
+
score: number | null;
|
|
12
|
+
dimensions: HealthGaugeDimension[];
|
|
13
|
+
totalDimensions?: number;
|
|
14
|
+
onDimensionClick?: (dimensionName: string) => void;
|
|
15
|
+
isLoading?: boolean;
|
|
16
|
+
lastFetchedAt?: number | null;
|
|
17
|
+
onRefresh?: () => void;
|
|
18
|
+
error?: Error | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const GRADE_BANDS: { min: number; grade: string; color: string }[] = [
|
|
22
|
+
{ min: 90, grade: 'A', color: '#22c55e' },
|
|
23
|
+
{ min: 75, grade: 'B', color: '#84cc16' },
|
|
24
|
+
{ min: 60, grade: 'C', color: '#eab308' },
|
|
25
|
+
{ min: 40, grade: 'D', color: '#f97316' },
|
|
26
|
+
{ min: 0, grade: 'F', color: '#ef4444' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const DIMENSION_LABELS: Record<string, string> = {
|
|
30
|
+
churn: 'Churn',
|
|
31
|
+
todo_density: 'TODO Density',
|
|
32
|
+
complexity: 'Complexity',
|
|
33
|
+
test_gaps: 'Test Gaps',
|
|
34
|
+
dead_code: 'Dead Code',
|
|
35
|
+
deprecation_debt: 'Deprecation Debt',
|
|
36
|
+
dependency_freshness: 'Dependency Freshness',
|
|
37
|
+
agent_context_efficiency: 'Agent Context Efficiency',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function getGrade(score: number): { grade: string; color: string } {
|
|
41
|
+
for (const band of GRADE_BANDS) {
|
|
42
|
+
if (score >= band.min) {
|
|
43
|
+
return { grade: band.grade, color: band.color };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { grade: 'F', color: '#ef4444' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// SVG arc helper for a semicircle gauge
|
|
50
|
+
function describeArc(cx: number, cy: number, r: number, startAngle: number, endAngle: number): string {
|
|
51
|
+
const start = polarToCartesian(cx, cy, r, endAngle);
|
|
52
|
+
const end = polarToCartesian(cx, cy, r, startAngle);
|
|
53
|
+
const largeArc = endAngle - startAngle <= 180 ? '0' : '1';
|
|
54
|
+
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 0 ${end.x} ${end.y}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
|
58
|
+
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
|
59
|
+
return {
|
|
60
|
+
x: cx + r * Math.cos(rad),
|
|
61
|
+
y: cy + r * Math.sin(rad),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatAge(ms: number): string {
|
|
66
|
+
const seconds = Math.floor(ms / 1000);
|
|
67
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
68
|
+
const minutes = Math.floor(seconds / 60);
|
|
69
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
70
|
+
const hours = Math.floor(minutes / 60);
|
|
71
|
+
return `${hours}h ago`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function HealthGauge({ score, dimensions, totalDimensions, onDimensionClick, isLoading, lastFetchedAt, onRefresh, error }: HealthGaugeProps): React.ReactElement {
|
|
75
|
+
const hasData = score !== null && score !== undefined;
|
|
76
|
+
const gradeInfo = hasData ? getGrade(score) : null;
|
|
77
|
+
const fillAngle = hasData ? (score / 100) * 180 : 0;
|
|
78
|
+
|
|
79
|
+
// Live-updating age display
|
|
80
|
+
const [ageText, setAgeText] = useState<string | null>(null);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!lastFetchedAt) {
|
|
83
|
+
setAgeText(null);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const tick = () => setAgeText(formatAge(Date.now() - lastFetchedAt));
|
|
87
|
+
tick();
|
|
88
|
+
const id = setInterval(tick, 10_000);
|
|
89
|
+
return () => clearInterval(id);
|
|
90
|
+
}, [lastFetchedAt]);
|
|
91
|
+
|
|
92
|
+
// Use all 8 dimension keys so rows always render (even before data arrives)
|
|
93
|
+
const allDimKeys = Object.keys(DIMENSION_LABELS);
|
|
94
|
+
const dimMap = new Map(dimensions.map((d) => [d.name, d]));
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
data-testid="health-gauge"
|
|
99
|
+
data-grade={gradeInfo?.grade ?? null}
|
|
100
|
+
>
|
|
101
|
+
<div className="health-gauge-header">
|
|
102
|
+
<div className="health-gauge-status">
|
|
103
|
+
{ageText && <span className="health-gauge-age" data-testid="health-gauge-age">{ageText}</span>}
|
|
104
|
+
{error && <span className="health-gauge-error" data-testid="health-gauge-error">Failed</span>}
|
|
105
|
+
</div>
|
|
106
|
+
{onRefresh && (
|
|
107
|
+
<Button
|
|
108
|
+
variant="outline"
|
|
109
|
+
size="sm"
|
|
110
|
+
className="health-gauge-refresh"
|
|
111
|
+
data-testid="health-gauge-refresh"
|
|
112
|
+
onClick={onRefresh}
|
|
113
|
+
disabled={isLoading}
|
|
114
|
+
>
|
|
115
|
+
{isLoading ? 'Analyzing...' : hasData ? 'Refresh' : 'Analyze'}
|
|
116
|
+
</Button>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<svg viewBox="0 0 200 120" width="200" height="120" className={isLoading ? 'opacity-50' : ''}>
|
|
121
|
+
{/* Background arc (grey) */}
|
|
122
|
+
<path
|
|
123
|
+
d={describeArc(100, 100, 80, 0, 180)}
|
|
124
|
+
fill="none"
|
|
125
|
+
stroke="#333"
|
|
126
|
+
strokeWidth="12"
|
|
127
|
+
strokeLinecap="round"
|
|
128
|
+
/>
|
|
129
|
+
{/* Fill arc (colored by grade) */}
|
|
130
|
+
{hasData && fillAngle > 0 && (
|
|
131
|
+
<path
|
|
132
|
+
d={describeArc(100, 100, 80, 0, fillAngle)}
|
|
133
|
+
fill="none"
|
|
134
|
+
stroke={gradeInfo!.color}
|
|
135
|
+
strokeWidth="12"
|
|
136
|
+
strokeLinecap="round"
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
{/* Score text */}
|
|
140
|
+
<text x="100" y="85" textAnchor="middle" fontSize="28" fill="currentColor">
|
|
141
|
+
{hasData ? String(Math.round(score)) : '--'}
|
|
142
|
+
</text>
|
|
143
|
+
{/* Grade letter */}
|
|
144
|
+
{gradeInfo && (
|
|
145
|
+
<text x="100" y="108" textAnchor="middle" fontSize="16" fill={gradeInfo.color}>
|
|
146
|
+
{gradeInfo.grade}
|
|
147
|
+
</text>
|
|
148
|
+
)}
|
|
149
|
+
</svg>
|
|
150
|
+
|
|
151
|
+
{/* Dimension count for partial data */}
|
|
152
|
+
{hasData && totalDimensions && dimensions.length < totalDimensions && (
|
|
153
|
+
<div className="health-gauge-partial">
|
|
154
|
+
{dimensions.length} of {totalDimensions} dimensions
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{/* Dimension breakdown — always visible, each row opens its dialog */}
|
|
159
|
+
<div data-testid="dimension-breakdown" className="health-gauge-breakdown">
|
|
160
|
+
{allDimKeys.map((dimName) => {
|
|
161
|
+
const dim = dimMap.get(dimName);
|
|
162
|
+
return (
|
|
163
|
+
<div
|
|
164
|
+
key={dimName}
|
|
165
|
+
data-testid={`dimension-${dimName}`}
|
|
166
|
+
className="health-gauge-dimension"
|
|
167
|
+
onClick={() => onDimensionClick?.(dimName)}
|
|
168
|
+
>
|
|
169
|
+
<span className="dimension-label">
|
|
170
|
+
{DIMENSION_LABELS[dimName] || dimName}
|
|
171
|
+
</span>
|
|
172
|
+
<span className="dimension-score">
|
|
173
|
+
{dim?.score !== null && dim?.score !== undefined ? dim.score.toFixed(1) : '--'}
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -24,7 +24,7 @@ import ToolStack from './ToolStack';
|
|
|
24
24
|
import SubagentSpan from './SubagentSpan';
|
|
25
25
|
import QuickActions from './QuickActions';
|
|
26
26
|
import { Separator } from '@/components/ui/separator';
|
|
27
|
-
import { isSkillContent } from '../utils/messageFilters';
|
|
27
|
+
import { isSkillContent, extractSkillLabel } from '../utils/messageFilters';
|
|
28
28
|
import { groupToolsIntoStacks, ToolStackData } from '../utils/toolStackGrouper';
|
|
29
29
|
import { usePersona } from '../hooks/usePersona';
|
|
30
30
|
import { useColorScheme } from '../hooks/useColorScheme';
|
|
@@ -121,9 +121,26 @@ export default function MessageView({ messages }: MessageViewProps): React.React
|
|
|
121
121
|
const filtered: MessageData[] = [];
|
|
122
122
|
const subagentGroups = new Map<string, SubagentGroup>();
|
|
123
123
|
|
|
124
|
+
// Track whether we've already emitted a skill label for this skill invocation.
|
|
125
|
+
// The first skill message gets replaced with a label; subsequent ones are dropped.
|
|
126
|
+
let pendingSkillLabel = false;
|
|
127
|
+
|
|
124
128
|
for (const msg of messages) {
|
|
125
129
|
if (msg.type === 'tool_result') continue;
|
|
126
|
-
if (msg.type === 'user' && isSkillContent(msg.content))
|
|
130
|
+
if (msg.type === 'user' && isSkillContent(msg.content)) {
|
|
131
|
+
const label = extractSkillLabel(msg.content);
|
|
132
|
+
if (label && !pendingSkillLabel) {
|
|
133
|
+
// Replace the first skill message with a short label
|
|
134
|
+
pendingSkillLabel = true;
|
|
135
|
+
filtered.push({ ...msg, content: label });
|
|
136
|
+
}
|
|
137
|
+
// Drop all other skill body messages (pf agent start, <purpose>, etc.)
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// Any non-skill user message resets the skill label tracker
|
|
141
|
+
if (msg.type === 'user') {
|
|
142
|
+
pendingSkillLabel = false;
|
|
143
|
+
}
|
|
127
144
|
if (msg.parent_id) {
|
|
128
145
|
let group = subagentGroups.get(msg.parent_id);
|
|
129
146
|
if (!group) {
|
|
@@ -271,7 +288,7 @@ export default function MessageView({ messages }: MessageViewProps): React.React
|
|
|
271
288
|
<img
|
|
272
289
|
src={colorScheme === 'dark' ? '/images/cyclist-dark.png' : '/images/cyclist-light.png'}
|
|
273
290
|
alt="Cyclist"
|
|
274
|
-
style={{ height: '2.5rem',
|
|
291
|
+
style={{ height: '2.5rem', opacity: 0.6, display: 'block', margin: '0 auto 0.5rem' }}
|
|
275
292
|
/>
|
|
276
293
|
<div>Type <code style={{
|
|
277
294
|
background: 'var(--bg-tertiary, #0f0f1a)',
|
|
@@ -331,9 +348,6 @@ export default function MessageView({ messages }: MessageViewProps): React.React
|
|
|
331
348
|
<span className="turn-speaker">
|
|
332
349
|
{turn.speaker === 'user' ? userName : agentName}
|
|
333
350
|
</span>
|
|
334
|
-
<span className="turn-timestamp">
|
|
335
|
-
{formatTurnTime(turn.timestamp)}
|
|
336
|
-
</span>
|
|
337
351
|
{turn.speaker === 'agent' && roleAbbrev && (
|
|
338
352
|
<Badge
|
|
339
353
|
variant="default"
|
|
@@ -343,6 +357,9 @@ export default function MessageView({ messages }: MessageViewProps): React.React
|
|
|
343
357
|
{roleAbbrev}
|
|
344
358
|
</Badge>
|
|
345
359
|
)}
|
|
360
|
+
<span className="turn-timestamp">
|
|
361
|
+
{formatTurnTime(turn.timestamp)}
|
|
362
|
+
</span>
|
|
346
363
|
</div>
|
|
347
364
|
{turn.items.map((item) => {
|
|
348
365
|
const idx = globalIdx++;
|
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
* - Accessible with ARIA labels
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import React, { useState, useCallback } from 'react';
|
|
18
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
19
19
|
import { Badge } from '@/components/ui/badge';
|
|
20
20
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
21
21
|
import { usePersona } from '../hooks/usePersona';
|
|
22
22
|
import { useColorScheme } from '../hooks/useColorScheme';
|
|
23
23
|
import { AgentPopup } from './AgentPopup';
|
|
24
|
+
import TandemPortrait from './TandemPortrait';
|
|
24
25
|
|
|
25
26
|
// Agent colors matching CLI statusbar (statusline.sh)
|
|
26
27
|
const AGENT_COLORS: Record<string, string> = {
|
|
@@ -59,7 +60,7 @@ function humanizeTheme(theme: string): string {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export default function PersonaHeader(): React.ReactElement {
|
|
62
|
-
const { persona } = usePersona();
|
|
63
|
+
const { persona, isStreaming } = usePersona();
|
|
63
64
|
const colorScheme = useColorScheme();
|
|
64
65
|
const [portraitError, setPortraitError] = useState(false);
|
|
65
66
|
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
|
@@ -70,6 +71,26 @@ export default function PersonaHeader(): React.ReactElement {
|
|
|
70
71
|
const role = persona?.role || 'agent';
|
|
71
72
|
const slug = persona?.slug;
|
|
72
73
|
const quote = persona?.quote;
|
|
74
|
+
const tandemAgent = persona?.tandemAgent;
|
|
75
|
+
|
|
76
|
+
// Observation pulse: one-shot animation on primary portrait when backseat starts thinking
|
|
77
|
+
const [observationPulse, setObservationPulse] = useState(false);
|
|
78
|
+
const prevThinkingRef = useRef(false);
|
|
79
|
+
const portraitRef = useRef<HTMLDivElement>(null);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const wasThinking = prevThinkingRef.current;
|
|
83
|
+
const isThinking = tandemAgent?.isThinking ?? false;
|
|
84
|
+
prevThinkingRef.current = isThinking;
|
|
85
|
+
|
|
86
|
+
if (!wasThinking && isThinking) {
|
|
87
|
+
setObservationPulse(true);
|
|
88
|
+
}
|
|
89
|
+
}, [tandemAgent?.isThinking]);
|
|
90
|
+
|
|
91
|
+
const handlePulseEnd = useCallback(() => {
|
|
92
|
+
setObservationPulse(false);
|
|
93
|
+
}, []);
|
|
73
94
|
|
|
74
95
|
const handleOpenPopup = useCallback(() => {
|
|
75
96
|
setIsPopupOpen(true);
|
|
@@ -101,7 +122,12 @@ export default function PersonaHeader(): React.ReactElement {
|
|
|
101
122
|
onKeyDown={(e) => e.key === 'Enter' && handleOpenPopup()}
|
|
102
123
|
>
|
|
103
124
|
<div className="persona-portrait-group">
|
|
104
|
-
<div
|
|
125
|
+
<div
|
|
126
|
+
className={`persona-portrait${isStreaming ? ' avatar-thinking' : ''}${observationPulse ? ' avatar-observation-pulse' : ''}`}
|
|
127
|
+
data-testid="persona-portrait"
|
|
128
|
+
ref={portraitRef}
|
|
129
|
+
onAnimationEnd={handlePulseEnd}
|
|
130
|
+
>
|
|
105
131
|
{slug && theme && !portraitError ? (
|
|
106
132
|
<img
|
|
107
133
|
src={`/portraits/${theme}/medium/${slug}.png`}
|
|
@@ -113,6 +139,23 @@ export default function PersonaHeader(): React.ReactElement {
|
|
|
113
139
|
<span className="portrait-fallback">🤖</span>
|
|
114
140
|
)}
|
|
115
141
|
</div>
|
|
142
|
+
{tandemAgent && (
|
|
143
|
+
<TandemPortrait
|
|
144
|
+
character={tandemAgent.character}
|
|
145
|
+
role={tandemAgent.role}
|
|
146
|
+
slug={tandemAgent.slug}
|
|
147
|
+
theme={tandemAgent.theme}
|
|
148
|
+
isActive={true}
|
|
149
|
+
isThinking={tandemAgent.isThinking}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
{tandemAgent && (
|
|
153
|
+
<span className="visually-hidden" role="status" aria-live="polite" data-testid="tandem-sr-status">
|
|
154
|
+
{tandemAgent.isThinking
|
|
155
|
+
? `${tandemAgent.character} is thinking`
|
|
156
|
+
: `${tandemAgent.character} observing`}
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
116
159
|
</div>
|
|
117
160
|
<div className="persona-info">
|
|
118
161
|
<div className="persona-name-row">
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TandemPortrait Component
|
|
3
|
+
*
|
|
4
|
+
* Renders backseat agent portrait below primary in PersonaHeader.
|
|
5
|
+
* Story: MSSCI-14674 (96-1) - TandemPortrait Component
|
|
6
|
+
* Epic: MSSCI-14673 (Cyclist Tandem UI)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useState } from 'react';
|
|
10
|
+
|
|
11
|
+
export interface TandemPortraitProps {
|
|
12
|
+
character: string;
|
|
13
|
+
role: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
theme: string;
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
isThinking: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const AGENT_ABBREV: Record<string, string> = {
|
|
21
|
+
pm: 'PM',
|
|
22
|
+
sm: 'SM',
|
|
23
|
+
dev: 'DEV',
|
|
24
|
+
tea: 'TEA',
|
|
25
|
+
reviewer: 'REV',
|
|
26
|
+
architect: 'ARC',
|
|
27
|
+
devops: 'OPS',
|
|
28
|
+
'ux-designer': 'UX',
|
|
29
|
+
'tech-writer': 'TW',
|
|
30
|
+
orchestrator: 'ORC',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default function TandemPortrait({
|
|
34
|
+
character,
|
|
35
|
+
role,
|
|
36
|
+
slug,
|
|
37
|
+
theme,
|
|
38
|
+
isActive,
|
|
39
|
+
isThinking,
|
|
40
|
+
}: TandemPortraitProps): React.ReactElement | null {
|
|
41
|
+
const [portraitError, setPortraitError] = useState(false);
|
|
42
|
+
|
|
43
|
+
if (!isActive) return null;
|
|
44
|
+
|
|
45
|
+
const abbrev = AGENT_ABBREV[role] || role.toUpperCase();
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className={`persona-tandem-portrait${isThinking ? ' avatar-tandem-thinking' : ''}`}
|
|
50
|
+
data-testid="tandem-portrait"
|
|
51
|
+
role="img"
|
|
52
|
+
aria-label={`${character} (${role}) - observing`}
|
|
53
|
+
tabIndex={-1}
|
|
54
|
+
>
|
|
55
|
+
{!portraitError ? (
|
|
56
|
+
<img
|
|
57
|
+
src={`/portraits/${theme}/medium/${slug}.png`}
|
|
58
|
+
alt=""
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
className="tandem-portrait-image"
|
|
61
|
+
onError={() => setPortraitError(true)}
|
|
62
|
+
/>
|
|
63
|
+
) : (
|
|
64
|
+
<span className="tandem-portrait-fallback" aria-hidden="true">🤖</span>
|
|
65
|
+
)}
|
|
66
|
+
<span className="tandem-role-badge" data-testid="tandem-role-badge" aria-hidden="true">
|
|
67
|
+
{abbrev}
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -104,6 +104,7 @@ export function getToolBadgeLabel(toolName: string): string {
|
|
|
104
104
|
export default function ToolCallBlock({ toolUse, result, className }: ToolCallBlockProps): React.ReactElement {
|
|
105
105
|
// AC1: Start collapsed by default
|
|
106
106
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
|
107
|
+
const [isPromptCollapsed, setIsPromptCollapsed] = useState(true);
|
|
107
108
|
// AC3: Track whether showing full content or truncated
|
|
108
109
|
const [showFullContent, setShowFullContent] = useState(false);
|
|
109
110
|
// AC4: Track copy state
|
|
@@ -112,6 +113,7 @@ export default function ToolCallBlock({ toolUse, result, className }: ToolCallBl
|
|
|
112
113
|
// MSSCI-13402: Determine error state for styling
|
|
113
114
|
const isError = result?.is_error === true;
|
|
114
115
|
const inputDisplay = formatToolInput(toolUse.tool_name, toolUse.input);
|
|
116
|
+
const paramCount = Object.keys(toolUse.input).length;
|
|
115
117
|
|
|
116
118
|
// MSSCI-13402: Get tool type CSS class
|
|
117
119
|
const toolTypeClass = getToolTypeClass(toolUse.tool_name);
|
|
@@ -178,17 +180,30 @@ export default function ToolCallBlock({ toolUse, result, className }: ToolCallBl
|
|
|
178
180
|
</TooltipTrigger>
|
|
179
181
|
<TooltipContent>{toolUse.tool_name}</TooltipContent>
|
|
180
182
|
</Tooltip>
|
|
181
|
-
<
|
|
182
|
-
<TooltipTrigger asChild>
|
|
183
|
-
<span className="tool-name">{intentSummary}</span>
|
|
184
|
-
</TooltipTrigger>
|
|
185
|
-
<TooltipContent>{inputDisplay}</TooltipContent>
|
|
186
|
-
</Tooltip>
|
|
183
|
+
<span className="tool-name">{intentSummary}</span>
|
|
187
184
|
{/* MSSCI-13402: Duration display */}
|
|
188
185
|
<span data-testid="tool-duration" className="tool-duration">
|
|
189
186
|
{result?.durationMs !== undefined ? formatDuration(result.durationMs) : ''}
|
|
190
187
|
</span>
|
|
191
188
|
</div>
|
|
189
|
+
{/* Prompt section - collapsible tool input display */}
|
|
190
|
+
<div className="tool-result-header">
|
|
191
|
+
<Button
|
|
192
|
+
variant="ghost"
|
|
193
|
+
size="sm"
|
|
194
|
+
data-testid="tool-prompt-toggle"
|
|
195
|
+
className="tool-result-toggle"
|
|
196
|
+
onClick={() => setIsPromptCollapsed(!isPromptCollapsed)}
|
|
197
|
+
>
|
|
198
|
+
{isPromptCollapsed ? '▶' : '▼'} Prompt ({paramCount} {paramCount === 1 ? 'param' : 'params'})
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
<div
|
|
202
|
+
data-testid="tool-prompt-content"
|
|
203
|
+
className={`tool-result-content ${isPromptCollapsed ? 'collapsed' : ''}`}
|
|
204
|
+
>
|
|
205
|
+
<pre>{inputDisplay}</pre>
|
|
206
|
+
</div>
|
|
192
207
|
{result && (
|
|
193
208
|
<>
|
|
194
209
|
<div className="tool-result-header">
|