@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.
Files changed (154) hide show
  1. package/dist/api/agent-load.d.ts +3 -0
  2. package/dist/api/agent-load.d.ts.map +1 -0
  3. package/dist/api/agent-load.js +124 -0
  4. package/dist/api/agent-load.js.map +1 -0
  5. package/dist/api/code-markers.d.ts +9 -0
  6. package/dist/api/code-markers.d.ts.map +1 -0
  7. package/dist/api/code-markers.js +62 -0
  8. package/dist/api/code-markers.js.map +1 -0
  9. package/dist/api/complexity.d.ts +3 -0
  10. package/dist/api/complexity.d.ts.map +1 -0
  11. package/dist/api/complexity.js +47 -0
  12. package/dist/api/complexity.js.map +1 -0
  13. package/dist/api/dead-code.d.ts +3 -0
  14. package/dist/api/dead-code.d.ts.map +1 -0
  15. package/dist/api/dead-code.js +70 -0
  16. package/dist/api/dead-code.js.map +1 -0
  17. package/dist/api/dependencies.d.ts +3 -0
  18. package/dist/api/dependencies.d.ts.map +1 -0
  19. package/dist/api/dependencies.js +43 -0
  20. package/dist/api/dependencies.js.map +1 -0
  21. package/dist/api/git.d.ts +3 -2
  22. package/dist/api/git.d.ts.map +1 -1
  23. package/dist/api/git.js +11 -6
  24. package/dist/api/git.js.map +1 -1
  25. package/dist/api/health-score.d.ts +3 -0
  26. package/dist/api/health-score.d.ts.map +1 -0
  27. package/dist/api/health-score.js +47 -0
  28. package/dist/api/health-score.js.map +1 -0
  29. package/dist/api/hotspots.d.ts.map +1 -1
  30. package/dist/api/hotspots.js +9 -1
  31. package/dist/api/hotspots.js.map +1 -1
  32. package/dist/api/index.d.ts +7 -1
  33. package/dist/api/index.d.ts.map +1 -1
  34. package/dist/api/index.js +12 -2
  35. package/dist/api/index.js.map +1 -1
  36. package/dist/api/persona.d.ts +2 -0
  37. package/dist/api/persona.d.ts.map +1 -1
  38. package/dist/api/persona.js +19 -1
  39. package/dist/api/persona.js.map +1 -1
  40. package/dist/api/settings.js +1 -1
  41. package/dist/api/settings.js.map +1 -1
  42. package/dist/claude-service.d.ts +8 -2
  43. package/dist/claude-service.d.ts.map +1 -1
  44. package/dist/claude-service.js +21 -2
  45. package/dist/claude-service.js.map +1 -1
  46. package/dist/git-diff.d.ts.map +1 -1
  47. package/dist/git-diff.js +6 -5
  48. package/dist/git-diff.js.map +1 -1
  49. package/dist/main.d.ts.map +1 -1
  50. package/dist/main.js +11 -2
  51. package/dist/main.js.map +1 -1
  52. package/dist/plugin-loader.d.ts +49 -0
  53. package/dist/plugin-loader.d.ts.map +1 -0
  54. package/dist/plugin-loader.js +92 -0
  55. package/dist/plugin-loader.js.map +1 -0
  56. package/dist/preload.js +12 -1
  57. package/dist/preload.js.map +1 -1
  58. package/dist/prime.d.ts +3 -2
  59. package/dist/prime.d.ts.map +1 -1
  60. package/dist/prime.js +25 -8
  61. package/dist/prime.js.map +1 -1
  62. package/dist/public/css/react.css +1 -1
  63. package/dist/public/js/react/react.js +50 -39
  64. package/dist/server.d.ts.map +1 -1
  65. package/dist/server.js +19 -16
  66. package/dist/server.js.map +1 -1
  67. package/dist/sprint-data.d.ts +6 -0
  68. package/dist/sprint-data.d.ts.map +1 -1
  69. package/dist/sprint-data.js +118 -67
  70. package/dist/sprint-data.js.map +1 -1
  71. package/dist/story-parser.js +1 -1
  72. package/dist/story-parser.js.map +1 -1
  73. package/dist/theme-metadata.js +2 -2
  74. package/dist/theme-metadata.js.map +1 -1
  75. package/dist/websocket.d.ts +0 -6
  76. package/dist/websocket.d.ts.map +1 -1
  77. package/dist/websocket.js +36 -40
  78. package/dist/websocket.js.map +1 -1
  79. package/package.json +2 -1
  80. package/portraits/fifth-element/large/cornelius-54343.png +0 -0
  81. package/portraits/fifth-element/large/diva-53453.png +0 -0
  82. package/portraits/fifth-element/large/korben-34232.png +0 -0
  83. package/portraits/fifth-element/large/leeloo-54333.png +0 -0
  84. package/portraits/fifth-element/large/lindberg-34432.png +0 -0
  85. package/portraits/fifth-element/large/mondoshawan-55131.png +0 -0
  86. package/portraits/fifth-element/large/munro-25321.png +0 -0
  87. package/portraits/fifth-element/large/pacoli-45232.png +0 -0
  88. package/portraits/fifth-element/large/ruby-53544.png +0 -0
  89. package/portraits/fifth-element/large/zorg-45312.png +0 -0
  90. package/portraits/fifth-element/medium/cornelius-54343.png +0 -0
  91. package/portraits/fifth-element/medium/diva-53453.png +0 -0
  92. package/portraits/fifth-element/medium/korben-34232.png +0 -0
  93. package/portraits/fifth-element/medium/leeloo-54333.png +0 -0
  94. package/portraits/fifth-element/medium/lindberg-34432.png +0 -0
  95. package/portraits/fifth-element/medium/mondoshawan-55131.png +0 -0
  96. package/portraits/fifth-element/medium/munro-25321.png +0 -0
  97. package/portraits/fifth-element/medium/pacoli-45232.png +0 -0
  98. package/portraits/fifth-element/medium/ruby-53544.png +0 -0
  99. package/portraits/fifth-element/medium/zorg-45312.png +0 -0
  100. package/src/public/App.tsx +0 -2
  101. package/src/public/components/AgentLoadDialog.tsx +202 -0
  102. package/src/public/components/AgentPopup.tsx +3 -5
  103. package/src/public/components/ContextSparkline.tsx +56 -0
  104. package/src/public/components/ControlBar.tsx +140 -6
  105. package/src/public/components/DeadCodeDialog.tsx +169 -0
  106. package/src/public/components/DockviewWorkspace.tsx +0 -3
  107. package/src/public/components/FullFileTree.tsx +18 -4
  108. package/src/public/components/HealthGauge.tsx +181 -0
  109. package/src/public/components/MessageView.tsx +23 -6
  110. package/src/public/components/PersonaHeader.tsx +46 -3
  111. package/src/public/components/TandemPortrait.tsx +71 -0
  112. package/src/public/components/ToolCallBlock.tsx +21 -6
  113. package/src/public/components/dialogs/CodeMarkersDialog.tsx +169 -0
  114. package/src/public/components/dialogs/ComplexityDialog.tsx +163 -0
  115. package/src/public/components/dialogs/DependenciesDialog.tsx +120 -0
  116. package/src/public/components/dialogs/HotspotsDialog.tsx +451 -0
  117. package/src/public/components/dialogs/ToolDialog.tsx +43 -0
  118. package/src/public/components/panels/ACPanel.tsx +1 -1
  119. package/src/public/components/panels/AcceptanceCriteriaPanel.tsx +15 -30
  120. package/src/public/components/panels/DebugPanel.tsx +79 -3
  121. package/src/public/components/panels/GitPanel.tsx +25 -30
  122. package/src/public/components/panels/MessagePanel.tsx +44 -2
  123. package/src/public/components/panels/SettingsPanel.tsx +4 -4
  124. package/src/public/components/panels/SprintPanel.tsx +247 -123
  125. package/src/public/components/panels/index.ts +0 -1
  126. package/src/public/components/ui/dialog.tsx +3 -3
  127. package/src/public/css/theme-system.css +98 -11
  128. package/src/public/hooks/index.ts +4 -0
  129. package/src/public/hooks/useAgentLoad.ts +105 -0
  130. package/src/public/hooks/useCodeMarkers.ts +101 -0
  131. package/src/public/hooks/useColorScheme.ts +25 -10
  132. package/src/public/hooks/useComplexity.ts +80 -0
  133. package/src/public/hooks/useDeadCode.ts +99 -0
  134. package/src/public/hooks/useDependencies.ts +82 -0
  135. package/src/public/hooks/useHealthScore.ts +69 -0
  136. package/src/public/hooks/useHotspots.ts +11 -1
  137. package/src/public/hooks/usePersona.ts +26 -3
  138. package/src/public/hooks/useSprint.ts +7 -1
  139. package/src/public/styles/tailwind.css +389 -83
  140. package/src/public/utils/messageFilters.ts +77 -6
  141. package/src/public/utils/slash-commands.ts +3 -35
  142. package/dist/hooks/cyclist-pretooluse-hook.d.ts +0 -60
  143. package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +0 -1
  144. package/dist/hooks/cyclist-pretooluse-hook.js +0 -57
  145. package/dist/hooks/cyclist-pretooluse-hook.js.map +0 -1
  146. package/dist/hooks/pretooluse-hook.d.ts +0 -89
  147. package/dist/hooks/pretooluse-hook.d.ts.map +0 -1
  148. package/dist/hooks/pretooluse-hook.js +0 -235
  149. package/dist/hooks/pretooluse-hook.js.map +0 -1
  150. package/dist/notification-sound.d.ts +0 -59
  151. package/dist/notification-sound.d.ts.map +0 -1
  152. package/dist/notification-sound.js +0 -219
  153. package/dist/notification-sound.js.map +0 -1
  154. 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)) continue;
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', marginBottom: '0.5rem', opacity: 0.6 }}
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 className="persona-portrait" data-testid="persona-portrait">
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
- <Tooltip>
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">