@pennyfarthing/cyclist 9.2.0 → 9.4.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 (58) hide show
  1. package/dist/api/hotspots.d.ts +3 -0
  2. package/dist/api/hotspots.d.ts.map +1 -0
  3. package/dist/api/hotspots.js +54 -0
  4. package/dist/api/hotspots.js.map +1 -0
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/index.js.map +1 -1
  9. package/dist/api/settings.d.ts +1 -1
  10. package/dist/api/settings.d.ts.map +1 -1
  11. package/dist/api/settings.js +44 -17
  12. package/dist/api/settings.js.map +1 -1
  13. package/dist/main.d.ts +4 -0
  14. package/dist/main.d.ts.map +1 -1
  15. package/dist/main.js +7 -0
  16. package/dist/main.js.map +1 -1
  17. package/dist/public/css/react.css +1 -1
  18. package/dist/public/js/react/react.js +43 -39
  19. package/dist/server.d.ts.map +1 -1
  20. package/dist/server.js +3 -1
  21. package/dist/server.js.map +1 -1
  22. package/dist/story-parser.d.ts +17 -0
  23. package/dist/story-parser.d.ts.map +1 -1
  24. package/dist/story-parser.js +183 -13
  25. package/dist/story-parser.js.map +1 -1
  26. package/dist/websocket.d.ts.map +1 -1
  27. package/dist/websocket.js +5 -4
  28. package/dist/websocket.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/public/App.tsx +2 -0
  31. package/src/public/components/ControlBar.tsx +1 -1
  32. package/src/public/components/DockviewWorkspace.tsx +4 -0
  33. package/src/public/components/FontPicker/index.tsx +118 -33
  34. package/src/public/components/FullFileTree.tsx +223 -0
  35. package/src/public/components/Message.tsx +32 -10
  36. package/src/public/components/MessageView.tsx +176 -93
  37. package/src/public/components/PersonaHeader.tsx +45 -15
  38. package/src/public/components/SubagentSpan.tsx +15 -8
  39. package/src/public/components/ThemePalette/ThemePalette.css +2 -0
  40. package/src/public/components/ToolStack.tsx +23 -13
  41. package/src/public/components/panels/AuditLogPanel.tsx +140 -66
  42. package/src/public/components/panels/ChangedPanel.tsx +30 -44
  43. package/src/public/components/panels/HotspotsPanel.tsx +365 -0
  44. package/src/public/components/panels/MessagePanel.tsx +14 -2
  45. package/src/public/components/panels/SettingsPanel.tsx +10 -10
  46. package/src/public/components/panels/WorkflowPanel.tsx +85 -12
  47. package/src/public/components/panels/index.ts +1 -0
  48. package/src/public/components/ui/switch.tsx +2 -2
  49. package/src/public/css/theme-system.css +71 -43
  50. package/src/public/hooks/useFileBrowser.ts +71 -0
  51. package/src/public/hooks/useHotspots.ts +113 -0
  52. package/src/public/hooks/useStory.ts +12 -3
  53. package/src/public/images/cyclist-dark.png +0 -0
  54. package/src/public/images/cyclist-light.png +0 -0
  55. package/src/public/styles/tailwind.css +428 -69
  56. package/src/public/types/message.ts +4 -0
  57. package/src/public/utils/slash-commands.ts +1 -1
  58. package/src/public/utils/toolStackGrouper.ts +4 -5
@@ -8,12 +8,14 @@
8
8
  * - Render messages list with proper roles
9
9
  * - Handle streaming content display
10
10
  * - Markdown rendering with syntax highlighting
11
- * - Tool call blocks
11
+ * - Tool call blocks with stacking
12
12
  * - Subagent span grouping
13
+ * - Turn-based grouping with speaker labels
13
14
  * - Auto-scroll behavior
14
15
  */
15
16
 
16
17
  import React, { useRef, useState, useCallback, useMemo } from 'react';
18
+ import { Badge } from '@/components/ui/badge';
17
19
  import { Button } from '@/components/ui/button';
18
20
  import MessageList, { MessageListHandle } from './MessageList';
19
21
  import Message from './Message';
@@ -23,8 +25,23 @@ import SubagentSpan from './SubagentSpan';
23
25
  import QuickActions from './QuickActions';
24
26
  import { isSkillContent } from '../utils/messageFilters';
25
27
  import { groupToolsIntoStacks, ToolStackData } from '../utils/toolStackGrouper';
28
+ import { usePersona } from '../hooks/usePersona';
29
+ import { useStatsStrip } from '../hooks/useStatsStrip';
26
30
  import type { MessageData } from '../types/message';
27
31
 
32
+ // Agent colors matching CLI statusbar (from PersonaHeader)
33
+ const AGENT_COLORS: Record<string, string> = {
34
+ pm: '#a78bfa', sm: '#60a5fa', dev: '#4ade80', tea: '#2dd4bf',
35
+ reviewer: '#f87171', architect: '#fb923c', devops: '#22d3ee',
36
+ 'ux-designer': '#f0abfc', 'tech-writer': '#e5e5e5', orchestrator: '#e879f9',
37
+ };
38
+
39
+ const AGENT_ABBREV: Record<string, string> = {
40
+ pm: 'PM', sm: 'SM', dev: 'DEV', tea: 'TEA', reviewer: 'REV',
41
+ architect: 'ARC', devops: 'OPS', 'ux-designer': 'UX', 'tech-writer': 'TW',
42
+ orchestrator: 'ORC',
43
+ };
44
+
28
45
  interface MessageViewProps {
29
46
  messages: MessageData[];
30
47
  }
@@ -41,9 +58,36 @@ interface ToolStackGroup {
41
58
  stack: ToolStackData;
42
59
  }
43
60
 
61
+ type RenderItem = MessageData | SubagentGroup | ToolStackGroup;
62
+
63
+ interface Turn {
64
+ speaker: 'user' | 'agent';
65
+ items: RenderItem[];
66
+ timestamp: number;
67
+ }
68
+
69
+ function formatTurnTime(timestamp: number): string {
70
+ return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
71
+ }
72
+
73
+ /**
74
+ * Classify an item as 'user' or 'agent' for turn grouping.
75
+ * Tools, subagents, and stacks are all part of the agent's turn.
76
+ */
77
+ function speakerOf(item: RenderItem): 'user' | 'agent' {
78
+ if ('isToolStack' in item || 'messages' in item) return 'agent';
79
+ const msg = item as MessageData;
80
+ return (msg.type === 'user' || msg.type === 'bell_injected') ? 'user' : 'agent';
81
+ }
82
+
44
83
  export default function MessageView({ messages }: MessageViewProps): React.ReactElement {
45
84
  const messageListRef = useRef<MessageListHandle>(null);
46
85
  const [isAtBottom, setIsAtBottom] = useState(true);
86
+ const { persona } = usePersona();
87
+ const { projectInfo } = useStatsStrip();
88
+
89
+ // Persist subagent collapsed state across re-renders/remounts
90
+ const subagentCollapsedRef = useRef<Map<string, boolean>>(new Map());
47
91
 
48
92
  const handleScrollChange = useCallback((atBottom: boolean) => {
49
93
  setIsAtBottom(atBottom);
@@ -53,134 +97,133 @@ export default function MessageView({ messages }: MessageViewProps): React.React
53
97
  messageListRef.current?.scrollToBottom('smooth');
54
98
  }, []);
55
99
 
56
- // Find the last assistant message for QuickActions
100
+ // Find the last non-streaming assistant message for QuickActions
57
101
  const lastAssistantMessage = useMemo(() => {
58
102
  for (let i = messages.length - 1; i >= 0; i--) {
59
- if (messages[i].type === 'agent' && !messages[i].isStreaming) {
60
- return messages[i];
61
- }
103
+ if (messages[i].type === 'agent' && !messages[i].isStreaming) return messages[i];
62
104
  }
63
105
  return null;
64
106
  }, [messages]);
65
107
 
66
- // Group messages by subagent parent_id and consecutive tool uses
67
- const groupedContent = useMemo(() => {
68
- const result: (MessageData | SubagentGroup | ToolStackGroup)[] = [];
108
+ // Single memo: messages flat render items turns
109
+ const { turns, toolResults, lastAgentItemIndex } = useMemo(() => {
110
+ // 1. Index tool results by ID
111
+ const results = new Map<string, MessageData>();
112
+ for (const msg of messages) {
113
+ if (msg.type === 'tool_result' && msg.tool_id) results.set(msg.tool_id, msg);
114
+ }
115
+
116
+ // 2. Filter and collect subagent groups in one pass
117
+ const filtered: MessageData[] = [];
69
118
  const subagentGroups = new Map<string, SubagentGroup>();
70
119
 
71
- // First pass: collect tool results for matching
72
- const toolResults = new Map<string, MessageData>();
73
- messages.forEach(msg => {
74
- if (msg.type === 'tool_result' && msg.tool_id) {
75
- toolResults.set(msg.tool_id, msg);
76
- }
77
- });
78
-
79
- // Second pass: filter messages and group by subagent (excluding tool_use for now)
80
- const filteredMessages: MessageData[] = [];
81
- messages.forEach(msg => {
82
- // Filter out skill content from user messages (MSSCI-12783)
83
- if (msg.type === 'user' && isSkillContent(msg.content)) {
84
- return;
85
- }
86
- // Skip tool_result - rendered with tool_use
87
- if (msg.type === 'tool_result') {
88
- return;
89
- }
90
- // Skip messages with parent_id (subagent messages handled separately)
120
+ for (const msg of messages) {
121
+ if (msg.type === 'tool_result') continue;
122
+ if (msg.type === 'user' && isSkillContent(msg.content)) continue;
91
123
  if (msg.parent_id) {
92
124
  let group = subagentGroups.get(msg.parent_id);
93
125
  if (!group) {
94
- group = {
95
- parent_id: msg.parent_id,
96
- type: msg.subagent_type || 'unknown',
97
- name: msg.subagent_name || 'unnamed',
98
- messages: [],
99
- };
126
+ group = { parent_id: msg.parent_id, type: msg.subagent_type || 'unknown', name: msg.subagent_name || 'unnamed', messages: [] };
100
127
  subagentGroups.set(msg.parent_id, group);
101
128
  }
102
129
  group.messages.push(msg);
103
- return;
130
+ continue;
104
131
  }
105
- filteredMessages.push(msg);
106
- });
132
+ filtered.push(msg);
133
+ }
134
+
135
+ // 3. Build flat render list, replacing consecutive tool_use runs with stacks
136
+ const stacks = groupToolsIntoStacks(filtered);
137
+ const stackByToolId = new Map<string, ToolStackData>();
138
+ for (const stack of stacks) {
139
+ for (const tool of stack.tools) stackByToolId.set(tool.tool_id, stack);
140
+ }
107
141
 
108
- // Third pass: group consecutive tool_use messages into stacks
109
- const toolStacks = groupToolsIntoStacks(filteredMessages);
142
+ const items: RenderItem[] = [];
143
+ const emittedStacks = new Set<string>();
144
+ const emittedSubagents = new Set<string>();
110
145
 
111
- // Create a set of tool_ids that belong to stacks (2+ tools)
112
- const stackedToolIds = new Set<string>();
113
- toolStacks.forEach(stack => {
114
- if (stack.count >= 2) {
115
- stack.tools.forEach(tool => stackedToolIds.add(tool.tool_id));
146
+ for (const msg of filtered) {
147
+ if (msg.type === 'tool_use' && msg.tool_id && stackByToolId.has(msg.tool_id)) {
148
+ const stack = stackByToolId.get(msg.tool_id)!;
149
+ if (!emittedStacks.has(stack.stackId)) {
150
+ emittedStacks.add(stack.stackId);
151
+ items.push({ isToolStack: true, stack });
152
+ }
153
+ } else {
154
+ items.push(msg);
116
155
  }
117
- });
156
+ }
118
157
 
119
- // Fourth pass: build result array, inserting ToolStackGroups where appropriate
120
- let currentStackIndex = 0;
121
- let pendingStack: ToolStackData | null = null;
158
+ // Insert subagent groups at the position of their first parent_id occurrence
159
+ // (They appear in the agent's turn, after the Task tool_use that spawned them)
160
+ // For now, just append them — they'll naturally land in the agent turn
161
+ for (const [, group] of subagentGroups) {
162
+ if (!emittedSubagents.has(group.parent_id)) {
163
+ emittedSubagents.add(group.parent_id);
164
+ items.push(group);
165
+ }
166
+ }
122
167
 
123
- filteredMessages.forEach(msg => {
124
- if (msg.parent_id) {
125
- // Subagent messages - insert the group when we first see a message from it
126
- const group = subagentGroups.get(msg.parent_id);
127
- if (group && !result.includes(group)) {
128
- result.push(group);
129
- }
130
- } else if (msg.type === 'tool_use' && msg.tool_id && stackedToolIds.has(msg.tool_id)) {
131
- // This tool belongs to a stack
132
- const stack = toolStacks.find(s =>
133
- s.count >= 2 && s.tools.some(t => t.tool_id === msg.tool_id)
134
- );
135
- if (stack && (!pendingStack || pendingStack.stackId !== stack.stackId)) {
136
- // New stack - add it
137
- result.push({ isToolStack: true, stack });
138
- pendingStack = stack;
139
- }
140
- // Skip individual rendering - handled by ToolStack
141
- } else if (msg.type === 'tool_use') {
142
- // Single tool - render normally
143
- result.push(msg);
144
- pendingStack = null;
168
+ // 4. Find last agent message index (for throb control)
169
+ let lastAgent = -1;
170
+ for (let i = items.length - 1; i >= 0; i--) {
171
+ const item = items[i];
172
+ if (!('isToolStack' in item) && !('messages' in item) && (item as MessageData).type === 'agent') {
173
+ lastAgent = i;
174
+ break;
175
+ }
176
+ }
177
+
178
+ // 5. Group into turns
179
+ const turnList: Turn[] = [];
180
+ for (const item of items) {
181
+ const speaker = speakerOf(item);
182
+ const last = turnList[turnList.length - 1];
183
+ if (last && last.speaker === speaker) {
184
+ last.items.push(item);
145
185
  } else {
146
- // Non-tool message
147
- result.push(msg);
148
- pendingStack = null;
186
+ turnList.push({
187
+ speaker,
188
+ items: [item],
189
+ timestamp: ('timestamp' in item) ? (item as MessageData).timestamp : Date.now(),
190
+ });
149
191
  }
150
- });
192
+ }
151
193
 
152
- return { items: result, toolResults };
194
+ return { turns: turnList, toolResults: results, lastAgentItemIndex: lastAgent };
153
195
  }, [messages]);
154
196
 
155
- const renderItem = (item: MessageData | SubagentGroup | ToolStackGroup, index: number) => {
156
- // Check if this is a tool stack group
157
- if ('isToolStack' in item && item.isToolStack) {
197
+ const renderItem = (item: RenderItem, globalIndex: number, isFirstInTurn: boolean) => {
198
+ if ('isToolStack' in item) {
158
199
  return (
159
200
  <ToolStack
160
201
  key={`stack-${item.stack.stackId}`}
161
202
  stack={item.stack}
162
- toolResults={groupedContent.toolResults as Map<string, { type: 'tool_result'; tool_id: string; content: string; timestamp: number }>}
203
+ toolResults={toolResults as Map<string, { type: 'tool_result'; tool_id: string; content: string; timestamp: number }>}
163
204
  />
164
205
  );
165
206
  }
166
207
 
167
- // Check if this is a subagent group
168
- if ('messages' in item && Array.isArray(item.messages)) {
208
+ if ('messages' in item && 'parent_id' in item) {
209
+ const group = item as SubagentGroup;
210
+ const collapsed = subagentCollapsedRef.current.get(group.parent_id) ?? true;
169
211
  return (
170
212
  <SubagentSpan
171
- key={`subagent-${(item as SubagentGroup).parent_id}`}
172
- type={(item as SubagentGroup).type}
173
- name={(item as SubagentGroup).name}
174
- messages={(item as SubagentGroup).messages as any}
213
+ key={`subagent-${group.parent_id}`}
214
+ type={group.type}
215
+ name={group.name}
216
+ messages={group.messages as any}
217
+ defaultCollapsed={collapsed}
218
+ onCollapseChange={(c) => subagentCollapsedRef.current.set(group.parent_id, c)}
175
219
  />
176
220
  );
177
221
  }
178
222
 
179
- // It's a regular message
180
223
  const msg = item as MessageData;
181
224
 
182
225
  if (msg.type === 'tool_use' && msg.tool_name && msg.tool_id) {
183
- const result = groupedContent.toolResults.get(msg.tool_id);
226
+ const result = toolResults.get(msg.tool_id);
184
227
  return (
185
228
  <ToolCallBlock
186
229
  key={`tool-${msg.tool_id}`}
@@ -205,13 +248,15 @@ export default function MessageView({ messages }: MessageViewProps): React.React
205
248
 
206
249
  return (
207
250
  <Message
208
- key={`msg-${index}-${msg.timestamp}`}
251
+ key={`msg-${globalIndex}-${msg.timestamp}`}
209
252
  message={msg}
253
+ isLastAgentMessage={globalIndex === lastAgentItemIndex}
254
+ isFirstInTurn={isFirstInTurn}
210
255
  />
211
256
  );
212
257
  };
213
258
 
214
- // Show empty state when no messages - prompt to start with /sm
259
+ // Empty state
215
260
  if (messages.length === 0) {
216
261
  return (
217
262
  <div data-testid="message-view" className="message-view">
@@ -230,6 +275,9 @@ export default function MessageView({ messages }: MessageViewProps): React.React
230
275
  );
231
276
  }
232
277
 
278
+ // Track a running global index across turns for lastAgentItemIndex matching
279
+ let globalIdx = 0;
280
+
233
281
  return (
234
282
  <div data-testid="message-view" className="message-view" role="log" aria-live="polite">
235
283
  <MessageList
@@ -237,15 +285,51 @@ export default function MessageView({ messages }: MessageViewProps): React.React
237
285
  onScrollChange={handleScrollChange}
238
286
  autoScroll={isAtBottom}
239
287
  >
240
- {groupedContent.items.map((item, index) => renderItem(item, index))}
288
+ {turns.map((turn, turnIndex) => {
289
+ // Track which items in this turn are "first message" (non-tool, non-stack)
290
+ let seenMessage = false;
291
+
292
+ const agentName = persona?.character || 'Agent';
293
+ const role = persona?.role || null;
294
+ const roleAbbrev = role ? (AGENT_ABBREV[role] || role) : null;
295
+ const roleColor = role ? (AGENT_COLORS[role] || '#e879f9') : undefined;
296
+ const userName = projectInfo?.githubUsername || 'You';
297
+
298
+ return (
299
+ <div key={`turn-${turnIndex}`} className={`turn-group turn-${turn.speaker}`}>
300
+ <div className="turn-label">
301
+ <span className="turn-speaker">
302
+ {turn.speaker === 'user' ? userName : agentName}
303
+ </span>
304
+ <span className="turn-timestamp">
305
+ {formatTurnTime(turn.timestamp)}
306
+ </span>
307
+ {turn.speaker === 'agent' && roleAbbrev && (
308
+ <Badge
309
+ variant="default"
310
+ className="turn-role-badge"
311
+ style={{ backgroundColor: roleColor }}
312
+ >
313
+ {roleAbbrev}
314
+ </Badge>
315
+ )}
316
+ </div>
317
+ {turn.items.map((item) => {
318
+ const idx = globalIdx++;
319
+ const isMessage = !('isToolStack' in item) && !('messages' in item) && (item as MessageData).type !== 'tool_use';
320
+ const isFirst = isMessage && !seenMessage;
321
+ if (isMessage) seenMessage = true;
322
+ return renderItem(item, idx, isFirst);
323
+ })}
324
+ </div>
325
+ );
326
+ })}
241
327
  </MessageList>
242
328
 
243
- {/* Quick Actions - dedicated area outside message scroll */}
244
329
  {lastAssistantMessage && (
245
330
  <QuickActions message={lastAssistantMessage} />
246
331
  )}
247
332
 
248
- {/* Auto-scroll indicator */}
249
333
  <div
250
334
  data-testid="auto-scroll-indicator"
251
335
  data-active={isAtBottom.toString()}
@@ -253,7 +337,6 @@ export default function MessageView({ messages }: MessageViewProps): React.React
253
337
  style={{ display: 'none' }}
254
338
  />
255
339
 
256
- {/* Scroll to bottom button */}
257
340
  <Button
258
341
  variant="ghost"
259
342
  size="icon"
@@ -35,6 +35,20 @@ const AGENT_COLORS: Record<string, string> = {
35
35
  orchestrator: '#e879f9', // Magenta - coordination
36
36
  };
37
37
 
38
+ // Abbreviated role names for compact badge display
39
+ const AGENT_ABBREV: Record<string, string> = {
40
+ pm: 'PM',
41
+ sm: 'SM',
42
+ dev: 'DEV',
43
+ tea: 'TEA',
44
+ reviewer: 'REV',
45
+ architect: 'ARC',
46
+ devops: 'OPS',
47
+ 'ux-designer': 'UX',
48
+ 'tech-writer': 'TW',
49
+ orchestrator: 'ORC',
50
+ };
51
+
38
52
  // Convert kebab-case theme name to Title Case (e.g., "princess-bride" -> "Princess Bride")
39
53
  function humanizeTheme(theme: string): string {
40
54
  return theme
@@ -47,6 +61,7 @@ export default function PersonaHeader(): React.ReactElement {
47
61
  const { persona } = usePersona();
48
62
  const [portraitError, setPortraitError] = useState(false);
49
63
  const [isPopupOpen, setIsPopupOpen] = useState(false);
64
+ const [isCompact, setIsCompact] = useState(false);
50
65
 
51
66
  const character = persona?.character || 'Agent';
52
67
  const theme = persona?.theme || 'default';
@@ -74,7 +89,7 @@ export default function PersonaHeader(): React.ReactElement {
74
89
  <>
75
90
  <TooltipProvider delayDuration={300}>
76
91
  <div
77
- className="persona-header clickable"
92
+ className={`persona-header clickable${isCompact ? ' compact' : ''}`}
78
93
  data-testid="persona-header"
79
94
  role="button"
80
95
  tabIndex={0}
@@ -87,7 +102,7 @@ export default function PersonaHeader(): React.ReactElement {
87
102
  <div className="persona-portrait" data-testid="persona-portrait">
88
103
  {slug && theme && !portraitError ? (
89
104
  <img
90
- src={`/portraits/${theme}/small/${slug}.png`}
105
+ src={`/portraits/${theme}/medium/${slug}.png`}
91
106
  alt={character}
92
107
  className="portrait-image"
93
108
  onError={() => setPortraitError(true)}
@@ -96,22 +111,22 @@ export default function PersonaHeader(): React.ReactElement {
96
111
  <span className="portrait-fallback">🤖</span>
97
112
  )}
98
113
  </div>
99
- <Tooltip>
100
- <TooltipTrigger asChild>
101
- <Badge
102
- variant="default"
103
- className="persona-role"
104
- data-testid="persona-role"
105
- style={{ backgroundColor: roleColor }}
106
- >
107
- {role}
108
- </Badge>
109
- </TooltipTrigger>
110
- <TooltipContent>{role}</TooltipContent>
111
- </Tooltip>
112
114
  </div>
113
115
  <div className="persona-info">
114
116
  <div className="persona-name-row">
117
+ <Tooltip>
118
+ <TooltipTrigger asChild>
119
+ <Badge
120
+ variant="default"
121
+ className="persona-role"
122
+ data-testid="persona-role"
123
+ style={{ backgroundColor: roleColor }}
124
+ >
125
+ {AGENT_ABBREV[role] || role}
126
+ </Badge>
127
+ </TooltipTrigger>
128
+ <TooltipContent>{role}</TooltipContent>
129
+ </Tooltip>
115
130
  <Tooltip>
116
131
  <TooltipTrigger asChild>
117
132
  <span
@@ -149,6 +164,21 @@ export default function PersonaHeader(): React.ReactElement {
149
164
  </Tooltip>
150
165
  )}
151
166
  </div>
167
+ <img
168
+ src="/images/cyclist-dark.png"
169
+ alt="Cyclist"
170
+ className="persona-branding"
171
+ />
172
+ <button
173
+ className="persona-collapse-toggle"
174
+ onClick={(e) => {
175
+ e.stopPropagation();
176
+ setIsCompact(!isCompact);
177
+ }}
178
+ aria-label={isCompact ? 'Expand header' : 'Collapse header'}
179
+ >
180
+ {isCompact ? '▼' : '▲'}
181
+ </button>
152
182
  </div>
153
183
  </TooltipProvider>
154
184
 
@@ -20,6 +20,7 @@ interface SubagentSpanProps {
20
20
  name: string;
21
21
  messages: SubagentMessage[];
22
22
  defaultCollapsed?: boolean;
23
+ onCollapseChange?: (collapsed: boolean) => void;
23
24
  // MSSCI-12776: Theme-aware helper display (optional - can be overridden by props for testing)
24
25
  helperName?: string | null;
25
26
  helperStyle?: string | null;
@@ -30,7 +31,8 @@ export default function SubagentSpan({
30
31
  type,
31
32
  name,
32
33
  messages,
33
- defaultCollapsed = false,
34
+ defaultCollapsed = true,
35
+ onCollapseChange,
34
36
  helperName: propHelperName,
35
37
  helperStyle: propHelperStyle,
36
38
  friendlyMessage: propFriendlyMessage,
@@ -55,6 +57,9 @@ export default function SubagentSpan({
55
57
  const displayName = helperName || type;
56
58
  const displayMessage = friendlyMessage || name;
57
59
 
60
+ // Count non-result messages for the collapsed summary
61
+ const messageCount = messages.filter(m => m.type !== 'tool_result').length;
62
+
58
63
  // Group tool_use and tool_result by tool_id
59
64
  const toolResults = new Map<string, SubagentMessage>();
60
65
  messages.forEach(msg => {
@@ -92,16 +97,16 @@ export default function SubagentSpan({
92
97
  return null;
93
98
  }
94
99
 
95
- // Subagent prompts (user messages within subagent) get special styling
100
+ // Subagent prompts (user messages within subagent) truncated single line
96
101
  if (msg.type === 'user') {
102
+ const truncated = (msg.content || '').slice(0, 120).replace(/\n/g, ' ');
97
103
  return (
98
104
  <div
99
105
  key={`subagent-prompt-${index}`}
100
106
  data-testid="subagent-prompt"
101
107
  className="message message-subagent-prompt"
102
108
  >
103
- <div className="message-avatar">📋</div>
104
- <div className="message-content">{msg.content}</div>
109
+ <div className="message-content">{truncated}{(msg.content || '').length > 120 ? '...' : ''}</div>
105
110
  </div>
106
111
  );
107
112
  }
@@ -127,7 +132,11 @@ export default function SubagentSpan({
127
132
  <div
128
133
  data-testid="subagent-span-header"
129
134
  className="subagent-header"
130
- onClick={() => setIsCollapsed(!isCollapsed)}
135
+ onClick={() => {
136
+ const next = !isCollapsed;
137
+ setIsCollapsed(next);
138
+ onCollapseChange?.(next);
139
+ }}
131
140
  >
132
141
  <span className="subagent-toggle">{isCollapsed ? '▶' : '▼'}</span>
133
142
 
@@ -159,9 +168,7 @@ export default function SubagentSpan({
159
168
  {type}
160
169
  </Badge>
161
170
 
162
- {isCollapsed && (
163
- <span className="subagent-count">{messages.length} messages</span>
164
- )}
171
+ <span className="subagent-count">{messageCount}</span>
165
172
  </div>
166
173
  {!isCollapsed && (
167
174
  <div className="subagent-content">
@@ -59,6 +59,8 @@
59
59
 
60
60
  .theme-palette-popover {
61
61
  width: 260px;
62
+ background: var(--bg-secondary);
63
+ border: 1px solid var(--border);
62
64
  }
63
65
 
64
66
  /* ==========================================================================
@@ -43,19 +43,25 @@ export default function ToolStack({ stack, toolResults }: ToolStackProps): React
43
43
  prevIsActiveRef.current = stack.isActive;
44
44
  }, [stack.isActive]);
45
45
 
46
- // AC2: Count display with singular/plural
47
- const countText = stack.count === 1 ? '1 tool' : `${stack.count} tools`;
46
+ // Count display - just the number
47
+ const countText = `${stack.count}`;
48
+
49
+ // Active tool summary - show what's happening RIGHT NOW
50
+ const activeSummary = useMemo(() => {
51
+ if (stack.isActive && stack.tools.length > 0) {
52
+ // Show the most recent (last) tool's summary
53
+ const lastTool = stack.tools[stack.tools.length - 1];
54
+ return generateToolIntentSummary(lastTool.tool_name, lastTool.input);
55
+ }
56
+ return null;
57
+ }, [stack.tools, stack.isActive]);
48
58
 
49
59
  // Generate summary of tool intents for collapsed view
50
60
  const collapsedSummary = useMemo(() => {
51
- // Show first 2 tool intents, abbreviated
52
- const summaries = stack.tools.slice(0, 2).map(tool =>
53
- generateToolIntentSummary(tool.tool_name, tool.input)
54
- );
55
- if (stack.tools.length > 2) {
56
- return summaries.join(', ') + '...';
57
- }
58
- return summaries.join(', ');
61
+ // Show last tool's summary as the main description
62
+ if (stack.tools.length === 0) return '';
63
+ const lastTool = stack.tools[stack.tools.length - 1];
64
+ return generateToolIntentSummary(lastTool.tool_name, lastTool.input);
59
65
  }, [stack.tools]);
60
66
 
61
67
  // Compute tool type counts for mini badges
@@ -133,11 +139,15 @@ export default function ToolStack({ stack, toolResults }: ToolStackProps): React
133
139
  </span>
134
140
  <span
135
141
  data-testid="tool-stack-count"
136
- className="tool-stack-count"
142
+ className="tool-stack-count-badge"
137
143
  >
138
- {countText} {stack.isActive ? 'running' : 'completed'}
144
+ {countText}
139
145
  </span>
140
- {isCollapsed && !stack.isActive && (
146
+ {stack.isActive && activeSummary ? (
147
+ <span className="tool-stack-active-summary">
148
+ {activeSummary}
149
+ </span>
150
+ ) : (
141
151
  <>
142
152
  <span className="tool-stack-badges">
143
153
  <TooltipProvider delayDuration={300}>