@pennyfarthing/cyclist 9.3.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/api/hook-request.d.ts +11 -0
  2. package/dist/api/hook-request.d.ts.map +1 -1
  3. package/dist/api/hook-request.js +126 -28
  4. package/dist/api/hook-request.js.map +1 -1
  5. package/dist/api/hotspots.d.ts +3 -0
  6. package/dist/api/hotspots.d.ts.map +1 -0
  7. package/dist/api/hotspots.js +54 -0
  8. package/dist/api/hotspots.js.map +1 -0
  9. package/dist/api/index.d.ts +2 -0
  10. package/dist/api/index.d.ts.map +1 -1
  11. package/dist/api/index.js +3 -0
  12. package/dist/api/index.js.map +1 -1
  13. package/dist/api/permissions.d.ts +16 -0
  14. package/dist/api/permissions.d.ts.map +1 -0
  15. package/dist/api/permissions.js +67 -0
  16. package/dist/api/permissions.js.map +1 -0
  17. package/dist/api/settings.d.ts +1 -1
  18. package/dist/api/settings.d.ts.map +1 -1
  19. package/dist/api/settings.js +44 -17
  20. package/dist/api/settings.js.map +1 -1
  21. package/dist/api/theme-agents.d.ts +4 -0
  22. package/dist/api/theme-agents.d.ts.map +1 -1
  23. package/dist/api/theme-agents.js +3 -0
  24. package/dist/api/theme-agents.js.map +1 -1
  25. package/dist/approval-gate.d.ts +3 -75
  26. package/dist/approval-gate.d.ts.map +1 -1
  27. package/dist/approval-gate.js +4 -121
  28. package/dist/approval-gate.js.map +1 -1
  29. package/dist/hooks/cyclist-pretooluse-hook.d.ts +60 -0
  30. package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +1 -0
  31. package/dist/hooks/cyclist-pretooluse-hook.js +57 -0
  32. package/dist/hooks/cyclist-pretooluse-hook.js.map +1 -0
  33. package/dist/hooks/pretooluse-hook.d.ts +89 -0
  34. package/dist/hooks/pretooluse-hook.d.ts.map +1 -0
  35. package/dist/hooks/pretooluse-hook.js +235 -0
  36. package/dist/hooks/pretooluse-hook.js.map +1 -0
  37. package/dist/main.d.ts +1 -134
  38. package/dist/main.d.ts.map +1 -1
  39. package/dist/main.js +42 -373
  40. package/dist/main.js.map +1 -1
  41. package/dist/menu-builder.d.ts +7 -1
  42. package/dist/menu-builder.d.ts.map +1 -1
  43. package/dist/menu-builder.js +36 -1
  44. package/dist/menu-builder.js.map +1 -1
  45. package/dist/otlp-receiver.d.ts.map +1 -1
  46. package/dist/otlp-receiver.js +6 -0
  47. package/dist/otlp-receiver.js.map +1 -1
  48. package/dist/public/css/react.css +1 -1
  49. package/dist/public/js/react/react.js +42 -42
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/server.js +16 -3
  52. package/dist/server.js.map +1 -1
  53. package/dist/settings-store.d.ts +3 -1
  54. package/dist/settings-store.d.ts.map +1 -1
  55. package/dist/settings-store.js +18 -9
  56. package/dist/settings-store.js.map +1 -1
  57. package/dist/story-parser.d.ts +17 -0
  58. package/dist/story-parser.d.ts.map +1 -1
  59. package/dist/story-parser.js +183 -13
  60. package/dist/story-parser.js.map +1 -1
  61. package/dist/websocket.d.ts +1 -0
  62. package/dist/websocket.d.ts.map +1 -1
  63. package/dist/websocket.js +48 -5
  64. package/dist/websocket.js.map +1 -1
  65. package/dist/workflow-presets.d.ts +72 -0
  66. package/dist/workflow-presets.d.ts.map +1 -0
  67. package/dist/workflow-presets.js +93 -0
  68. package/dist/workflow-presets.js.map +1 -0
  69. package/package.json +2 -2
  70. package/src/public/App.tsx +61 -1
  71. package/src/public/components/ApprovalModal/index.tsx +31 -1
  72. package/src/public/components/ControlBar.tsx +19 -20
  73. package/src/public/components/DockviewWorkspace.tsx +39 -5
  74. package/src/public/components/FontPicker/index.tsx +118 -33
  75. package/src/public/components/FullFileTree.tsx +223 -0
  76. package/src/public/components/Message.tsx +89 -11
  77. package/src/public/components/MessageView.tsx +206 -93
  78. package/src/public/components/PersonaHeader.tsx +47 -15
  79. package/src/public/components/SubagentSpan.tsx +15 -8
  80. package/src/public/components/panels/BackgroundPanel.tsx +1 -1
  81. package/src/public/components/panels/ChangedPanel.tsx +30 -44
  82. package/src/public/components/panels/HotspotsPanel.tsx +365 -0
  83. package/src/public/components/panels/MessagePanel.tsx +79 -5
  84. package/src/public/components/panels/SettingsPanel.tsx +3 -28
  85. package/src/public/components/panels/WorkflowPanel.tsx +108 -13
  86. package/src/public/components/panels/index.ts +1 -0
  87. package/src/public/contexts/ClaudeContext.tsx +16 -1
  88. package/src/public/css/theme-system.css +46 -38
  89. package/src/public/hooks/useColorScheme.ts +27 -0
  90. package/src/public/hooks/useFileBrowser.ts +71 -0
  91. package/src/public/hooks/useHotspots.ts +113 -0
  92. package/src/public/hooks/usePlanModeExit.ts +105 -0
  93. package/src/public/hooks/useStory.ts +12 -3
  94. package/src/public/images/cyclist-dark.png +0 -0
  95. package/src/public/images/cyclist-light.png +0 -0
  96. package/src/public/styles/dockview-theme.css +31 -33
  97. package/src/public/styles/tailwind.css +417 -58
  98. package/src/public/types/message.ts +6 -1
  99. package/src/public/utils/markdown.ts +2 -2
  100. package/src/public/utils/slash-commands.ts +1 -1
  101. package/src/public/utils/toolStackGrouper.ts +5 -6
@@ -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';
@@ -21,10 +23,27 @@ import ToolCallBlock from './ToolCallBlock';
21
23
  import ToolStack from './ToolStack';
22
24
  import SubagentSpan from './SubagentSpan';
23
25
  import QuickActions from './QuickActions';
26
+ import { Separator } from '@/components/ui/separator';
24
27
  import { isSkillContent } from '../utils/messageFilters';
25
28
  import { groupToolsIntoStacks, ToolStackData } from '../utils/toolStackGrouper';
29
+ import { usePersona } from '../hooks/usePersona';
30
+ import { useColorScheme } from '../hooks/useColorScheme';
31
+ import { useStatsStrip } from '../hooks/useStatsStrip';
26
32
  import type { MessageData } from '../types/message';
27
33
 
34
+ // Agent colors matching CLI statusbar (from PersonaHeader)
35
+ const AGENT_COLORS: Record<string, string> = {
36
+ pm: '#a78bfa', sm: '#60a5fa', dev: '#4ade80', tea: '#2dd4bf',
37
+ reviewer: '#f87171', architect: '#fb923c', devops: '#22d3ee',
38
+ 'ux-designer': '#f0abfc', 'tech-writer': '#e5e5e5', orchestrator: '#e879f9',
39
+ };
40
+
41
+ const AGENT_ABBREV: Record<string, string> = {
42
+ pm: 'PM', sm: 'SM', dev: 'DEV', tea: 'TEA', reviewer: 'REV',
43
+ architect: 'ARC', devops: 'OPS', 'ux-designer': 'UX', 'tech-writer': 'TW',
44
+ orchestrator: 'ORC',
45
+ };
46
+
28
47
  interface MessageViewProps {
29
48
  messages: MessageData[];
30
49
  }
@@ -41,9 +60,38 @@ interface ToolStackGroup {
41
60
  stack: ToolStackData;
42
61
  }
43
62
 
63
+ type RenderItem = MessageData | SubagentGroup | ToolStackGroup;
64
+
65
+ interface Turn {
66
+ speaker: 'user' | 'agent' | 'system';
67
+ items: RenderItem[];
68
+ timestamp: number;
69
+ }
70
+
71
+ function formatTurnTime(timestamp: number): string {
72
+ return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
73
+ }
74
+
75
+ /**
76
+ * Classify an item as 'user' or 'agent' for turn grouping.
77
+ * Tools, subagents, and stacks are all part of the agent's turn.
78
+ */
79
+ function speakerOf(item: RenderItem): 'user' | 'agent' | 'system' {
80
+ if ('isToolStack' in item || 'messages' in item) return 'agent';
81
+ const msg = item as MessageData;
82
+ if (msg.type === 'context_cleared') return 'system';
83
+ return (msg.type === 'user' || msg.type === 'bell_injected') ? 'user' : 'agent';
84
+ }
85
+
44
86
  export default function MessageView({ messages }: MessageViewProps): React.ReactElement {
45
87
  const messageListRef = useRef<MessageListHandle>(null);
46
88
  const [isAtBottom, setIsAtBottom] = useState(true);
89
+ const { persona } = usePersona();
90
+ const colorScheme = useColorScheme();
91
+ const { projectInfo } = useStatsStrip();
92
+
93
+ // Persist subagent collapsed state across re-renders/remounts
94
+ const subagentCollapsedRef = useRef<Map<string, boolean>>(new Map());
47
95
 
48
96
  const handleScrollChange = useCallback((atBottom: boolean) => {
49
97
  setIsAtBottom(atBottom);
@@ -53,132 +101,133 @@ export default function MessageView({ messages }: MessageViewProps): React.React
53
101
  messageListRef.current?.scrollToBottom('smooth');
54
102
  }, []);
55
103
 
56
- // Find the last assistant message for QuickActions
104
+ // Find the last non-streaming assistant message for QuickActions
57
105
  const lastAssistantMessage = useMemo(() => {
58
106
  for (let i = messages.length - 1; i >= 0; i--) {
59
- if (messages[i].type === 'agent' && !messages[i].isStreaming) {
60
- return messages[i];
61
- }
107
+ if (messages[i].type === 'agent' && !messages[i].isStreaming) return messages[i];
62
108
  }
63
109
  return null;
64
110
  }, [messages]);
65
111
 
66
- // Group messages by subagent parent_id and consecutive tool uses
67
- const groupedContent = useMemo(() => {
68
- const result: (MessageData | SubagentGroup | ToolStackGroup)[] = [];
112
+ // Single memo: messages flat render items turns
113
+ const { turns, toolResults, lastAgentItemIndex } = useMemo(() => {
114
+ // 1. Index tool results by ID
115
+ const results = new Map<string, MessageData>();
116
+ for (const msg of messages) {
117
+ if (msg.type === 'tool_result' && msg.tool_id) results.set(msg.tool_id, msg);
118
+ }
119
+
120
+ // 2. Filter and collect subagent groups in one pass
121
+ const filtered: MessageData[] = [];
69
122
  const subagentGroups = new Map<string, SubagentGroup>();
70
123
 
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)
124
+ for (const msg of messages) {
125
+ if (msg.type === 'tool_result') continue;
126
+ if (msg.type === 'user' && isSkillContent(msg.content)) continue;
91
127
  if (msg.parent_id) {
92
128
  let group = subagentGroups.get(msg.parent_id);
93
129
  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
- };
130
+ group = { parent_id: msg.parent_id, type: msg.subagent_type || 'unknown', name: msg.subagent_name || 'unnamed', messages: [] };
100
131
  subagentGroups.set(msg.parent_id, group);
101
132
  }
102
133
  group.messages.push(msg);
103
- return;
134
+ continue;
104
135
  }
105
- filteredMessages.push(msg);
106
- });
107
-
108
- // Third pass: group consecutive tool_use messages into stacks
109
- const toolStacks = groupToolsIntoStacks(filteredMessages);
136
+ filtered.push(msg);
137
+ }
110
138
 
111
- // Create a set of tool_ids that belong to stacks
112
- const stackedToolIds = new Set<string>();
113
- toolStacks.forEach(stack => {
114
- stack.tools.forEach(tool => stackedToolIds.add(tool.tool_id));
115
- });
139
+ // 3. Build flat render list, replacing consecutive tool_use runs with stacks
140
+ const stacks = groupToolsIntoStacks(filtered);
141
+ const stackByToolId = new Map<string, ToolStackData>();
142
+ for (const stack of stacks) {
143
+ for (const tool of stack.tools) stackByToolId.set(tool.tool_id, stack);
144
+ }
116
145
 
117
- // Fourth pass: build result array, inserting ToolStackGroups where appropriate
118
- let currentStackIndex = 0;
119
- let pendingStack: ToolStackData | null = null;
146
+ const items: RenderItem[] = [];
147
+ const emittedStacks = new Set<string>();
148
+ const emittedSubagents = new Set<string>();
120
149
 
121
- filteredMessages.forEach(msg => {
122
- if (msg.parent_id) {
123
- // Subagent messages - insert the group when we first see a message from it
124
- const group = subagentGroups.get(msg.parent_id);
125
- if (group && !result.includes(group)) {
126
- result.push(group);
150
+ for (const msg of filtered) {
151
+ if (msg.type === 'tool_use' && msg.tool_id && stackByToolId.has(msg.tool_id)) {
152
+ const stack = stackByToolId.get(msg.tool_id)!;
153
+ if (!emittedStacks.has(stack.stackId)) {
154
+ emittedStacks.add(stack.stackId);
155
+ items.push({ isToolStack: true, stack });
127
156
  }
128
- } else if (msg.type === 'tool_use' && msg.tool_id && stackedToolIds.has(msg.tool_id)) {
129
- // This tool belongs to a stack
130
- const stack = toolStacks.find(s =>
131
- s.tools.some(t => t.tool_id === msg.tool_id)
132
- );
133
- if (stack && (!pendingStack || pendingStack.stackId !== stack.stackId)) {
134
- // New stack - add it
135
- result.push({ isToolStack: true, stack });
136
- pendingStack = stack;
137
- }
138
- // Skip individual rendering - handled by ToolStack
139
- } else if (msg.type === 'tool_use') {
140
- // Single tool - render normally
141
- result.push(msg);
142
- pendingStack = null;
143
157
  } else {
144
- // Non-tool message
145
- result.push(msg);
146
- pendingStack = null;
158
+ items.push(msg);
159
+ }
160
+ }
161
+
162
+ // Insert subagent groups at the position of their first parent_id occurrence
163
+ // (They appear in the agent's turn, after the Task tool_use that spawned them)
164
+ // For now, just append them — they'll naturally land in the agent turn
165
+ for (const [, group] of subagentGroups) {
166
+ if (!emittedSubagents.has(group.parent_id)) {
167
+ emittedSubagents.add(group.parent_id);
168
+ items.push(group);
147
169
  }
148
- });
170
+ }
149
171
 
150
- return { items: result, toolResults };
172
+ // 4. Find last agent message index (for throb control)
173
+ let lastAgent = -1;
174
+ for (let i = items.length - 1; i >= 0; i--) {
175
+ const item = items[i];
176
+ if (!('isToolStack' in item) && !('messages' in item) && (item as MessageData).type === 'agent') {
177
+ lastAgent = i;
178
+ break;
179
+ }
180
+ }
181
+
182
+ // 5. Group into turns
183
+ const turnList: Turn[] = [];
184
+ for (const item of items) {
185
+ const speaker = speakerOf(item);
186
+ const last = turnList[turnList.length - 1];
187
+ if (last && last.speaker === speaker) {
188
+ last.items.push(item);
189
+ } else {
190
+ turnList.push({
191
+ speaker,
192
+ items: [item],
193
+ timestamp: ('timestamp' in item) ? (item as MessageData).timestamp : Date.now(),
194
+ });
195
+ }
196
+ }
197
+
198
+ return { turns: turnList, toolResults: results, lastAgentItemIndex: lastAgent };
151
199
  }, [messages]);
152
200
 
153
- const renderItem = (item: MessageData | SubagentGroup | ToolStackGroup, index: number) => {
154
- // Check if this is a tool stack group
155
- if ('isToolStack' in item && item.isToolStack) {
201
+ const renderItem = (item: RenderItem, globalIndex: number, isFirstInTurn: boolean) => {
202
+ if ('isToolStack' in item) {
156
203
  return (
157
204
  <ToolStack
158
205
  key={`stack-${item.stack.stackId}`}
159
206
  stack={item.stack}
160
- toolResults={groupedContent.toolResults as Map<string, { type: 'tool_result'; tool_id: string; content: string; timestamp: number }>}
207
+ toolResults={toolResults as Map<string, { type: 'tool_result'; tool_id: string; content: string; timestamp: number }>}
161
208
  />
162
209
  );
163
210
  }
164
211
 
165
- // Check if this is a subagent group
166
- if ('messages' in item && Array.isArray(item.messages)) {
212
+ if ('messages' in item && 'parent_id' in item) {
213
+ const group = item as SubagentGroup;
214
+ const collapsed = subagentCollapsedRef.current.get(group.parent_id) ?? true;
167
215
  return (
168
216
  <SubagentSpan
169
- key={`subagent-${(item as SubagentGroup).parent_id}`}
170
- type={(item as SubagentGroup).type}
171
- name={(item as SubagentGroup).name}
172
- messages={(item as SubagentGroup).messages as any}
217
+ key={`subagent-${group.parent_id}`}
218
+ type={group.type}
219
+ name={group.name}
220
+ messages={group.messages as any}
221
+ defaultCollapsed={collapsed}
222
+ onCollapseChange={(c) => subagentCollapsedRef.current.set(group.parent_id, c)}
173
223
  />
174
224
  );
175
225
  }
176
226
 
177
- // It's a regular message
178
227
  const msg = item as MessageData;
179
228
 
180
229
  if (msg.type === 'tool_use' && msg.tool_name && msg.tool_id) {
181
- const result = groupedContent.toolResults.get(msg.tool_id);
230
+ const result = toolResults.get(msg.tool_id);
182
231
  return (
183
232
  <ToolCallBlock
184
233
  key={`tool-${msg.tool_id}`}
@@ -203,19 +252,25 @@ export default function MessageView({ messages }: MessageViewProps): React.React
203
252
 
204
253
  return (
205
254
  <Message
206
- key={`msg-${index}-${msg.timestamp}`}
255
+ key={`msg-${globalIndex}-${msg.timestamp}`}
207
256
  message={msg}
257
+ isLastAgentMessage={globalIndex === lastAgentItemIndex}
258
+ isFirstInTurn={isFirstInTurn}
208
259
  />
209
260
  );
210
261
  };
211
262
 
212
- // Show empty state when no messages - prompt to start with /sm
263
+ // Empty state
213
264
  if (messages.length === 0) {
214
265
  return (
215
266
  <div data-testid="message-view" className="message-view">
216
267
  <div className="message-view-empty">
217
268
  <div>
218
- <div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>🚴</div>
269
+ <img
270
+ src={colorScheme === 'dark' ? '/images/cyclist-dark.png' : '/images/cyclist-light.png'}
271
+ alt="Cyclist"
272
+ style={{ height: '2.5rem', marginBottom: '0.5rem', opacity: 0.6 }}
273
+ />
219
274
  <div>Type <code style={{
220
275
  background: 'var(--bg-tertiary, #0f0f1a)',
221
276
  padding: '2px 6px',
@@ -228,6 +283,9 @@ export default function MessageView({ messages }: MessageViewProps): React.React
228
283
  );
229
284
  }
230
285
 
286
+ // Track a running global index across turns for lastAgentItemIndex matching
287
+ let globalIdx = 0;
288
+
231
289
  return (
232
290
  <div data-testid="message-view" className="message-view" role="log" aria-live="polite">
233
291
  <MessageList
@@ -235,15 +293,71 @@ export default function MessageView({ messages }: MessageViewProps): React.React
235
293
  onScrollChange={handleScrollChange}
236
294
  autoScroll={isAtBottom}
237
295
  >
238
- {groupedContent.items.map((item, index) => renderItem(item, index))}
296
+ {turns.map((turn, turnIndex) => {
297
+ // System turns (context_cleared) render as a divider bar
298
+ if (turn.speaker === 'system') {
299
+ // Still increment globalIdx for system items
300
+ turn.items.forEach(() => globalIdx++);
301
+ return (
302
+ <div key={`turn-${turnIndex}`} className="turn-group turn-system">
303
+ <div className="context-cleared-bar">
304
+ <Separator className="context-cleared-line" />
305
+ <span className="context-cleared-label">
306
+ Context cleared
307
+ </span>
308
+ <span className="context-cleared-time">
309
+ {formatTurnTime(turn.timestamp)}
310
+ </span>
311
+ <Separator className="context-cleared-line" />
312
+ </div>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ // Track which items in this turn are "first message" (non-tool, non-stack)
318
+ let seenMessage = false;
319
+
320
+ const agentName = persona?.character || 'Agent';
321
+ const role = persona?.role || null;
322
+ const roleAbbrev = role ? (AGENT_ABBREV[role] || role) : null;
323
+ const roleColor = role ? (AGENT_COLORS[role] || '#e879f9') : undefined;
324
+ const userName = projectInfo?.githubUsername || 'You';
325
+
326
+ return (
327
+ <div key={`turn-${turnIndex}`} className={`turn-group turn-${turn.speaker}`}>
328
+ <div className="turn-label">
329
+ <span className="turn-speaker">
330
+ {turn.speaker === 'user' ? userName : agentName}
331
+ </span>
332
+ <span className="turn-timestamp">
333
+ {formatTurnTime(turn.timestamp)}
334
+ </span>
335
+ {turn.speaker === 'agent' && roleAbbrev && (
336
+ <Badge
337
+ variant="default"
338
+ className="turn-role-badge"
339
+ style={{ backgroundColor: roleColor }}
340
+ >
341
+ {roleAbbrev}
342
+ </Badge>
343
+ )}
344
+ </div>
345
+ {turn.items.map((item) => {
346
+ const idx = globalIdx++;
347
+ const isMessage = !('isToolStack' in item) && !('messages' in item) && (item as MessageData).type !== 'tool_use';
348
+ const isFirst = isMessage && !seenMessage;
349
+ if (isMessage) seenMessage = true;
350
+ return renderItem(item, idx, isFirst);
351
+ })}
352
+ </div>
353
+ );
354
+ })}
239
355
  </MessageList>
240
356
 
241
- {/* Quick Actions - dedicated area outside message scroll */}
242
357
  {lastAssistantMessage && (
243
358
  <QuickActions message={lastAssistantMessage} />
244
359
  )}
245
360
 
246
- {/* Auto-scroll indicator */}
247
361
  <div
248
362
  data-testid="auto-scroll-indicator"
249
363
  data-active={isAtBottom.toString()}
@@ -251,7 +365,6 @@ export default function MessageView({ messages }: MessageViewProps): React.React
251
365
  style={{ display: 'none' }}
252
366
  />
253
367
 
254
- {/* Scroll to bottom button */}
255
368
  <Button
256
369
  variant="ghost"
257
370
  size="icon"
@@ -19,6 +19,7 @@ import React, { useState, useCallback } 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
+ import { useColorScheme } from '../hooks/useColorScheme';
22
23
  import { AgentPopup } from './AgentPopup';
23
24
 
24
25
  // Agent colors matching CLI statusbar (statusline.sh)
@@ -35,6 +36,20 @@ const AGENT_COLORS: Record<string, string> = {
35
36
  orchestrator: '#e879f9', // Magenta - coordination
36
37
  };
37
38
 
39
+ // Abbreviated role names for compact badge display
40
+ const AGENT_ABBREV: Record<string, string> = {
41
+ pm: 'PM',
42
+ sm: 'SM',
43
+ dev: 'DEV',
44
+ tea: 'TEA',
45
+ reviewer: 'REV',
46
+ architect: 'ARC',
47
+ devops: 'OPS',
48
+ 'ux-designer': 'UX',
49
+ 'tech-writer': 'TW',
50
+ orchestrator: 'ORC',
51
+ };
52
+
38
53
  // Convert kebab-case theme name to Title Case (e.g., "princess-bride" -> "Princess Bride")
39
54
  function humanizeTheme(theme: string): string {
40
55
  return theme
@@ -45,8 +60,10 @@ function humanizeTheme(theme: string): string {
45
60
 
46
61
  export default function PersonaHeader(): React.ReactElement {
47
62
  const { persona } = usePersona();
63
+ const colorScheme = useColorScheme();
48
64
  const [portraitError, setPortraitError] = useState(false);
49
65
  const [isPopupOpen, setIsPopupOpen] = useState(false);
66
+ const [isCompact, setIsCompact] = useState(false);
50
67
 
51
68
  const character = persona?.character || 'Agent';
52
69
  const theme = persona?.theme || 'default';
@@ -74,7 +91,7 @@ export default function PersonaHeader(): React.ReactElement {
74
91
  <>
75
92
  <TooltipProvider delayDuration={300}>
76
93
  <div
77
- className="persona-header clickable"
94
+ className={`persona-header clickable${isCompact ? ' compact' : ''}`}
78
95
  data-testid="persona-header"
79
96
  role="button"
80
97
  tabIndex={0}
@@ -87,7 +104,7 @@ export default function PersonaHeader(): React.ReactElement {
87
104
  <div className="persona-portrait" data-testid="persona-portrait">
88
105
  {slug && theme && !portraitError ? (
89
106
  <img
90
- src={`/portraits/${theme}/small/${slug}.png`}
107
+ src={`/portraits/${theme}/medium/${slug}.png`}
91
108
  alt={character}
92
109
  className="portrait-image"
93
110
  onError={() => setPortraitError(true)}
@@ -96,22 +113,22 @@ export default function PersonaHeader(): React.ReactElement {
96
113
  <span className="portrait-fallback">🤖</span>
97
114
  )}
98
115
  </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
116
  </div>
113
117
  <div className="persona-info">
114
118
  <div className="persona-name-row">
119
+ <Tooltip>
120
+ <TooltipTrigger asChild>
121
+ <Badge
122
+ variant="default"
123
+ className="persona-role"
124
+ data-testid="persona-role"
125
+ style={{ backgroundColor: roleColor }}
126
+ >
127
+ {AGENT_ABBREV[role] || role}
128
+ </Badge>
129
+ </TooltipTrigger>
130
+ <TooltipContent>{role}</TooltipContent>
131
+ </Tooltip>
115
132
  <Tooltip>
116
133
  <TooltipTrigger asChild>
117
134
  <span
@@ -149,6 +166,21 @@ export default function PersonaHeader(): React.ReactElement {
149
166
  </Tooltip>
150
167
  )}
151
168
  </div>
169
+ <img
170
+ src={colorScheme === 'dark' ? '/images/cyclist-dark.png' : '/images/cyclist-light.png'}
171
+ alt="Cyclist"
172
+ className="persona-branding"
173
+ />
174
+ <button
175
+ className="persona-collapse-toggle"
176
+ onClick={(e) => {
177
+ e.stopPropagation();
178
+ setIsCompact(!isCompact);
179
+ }}
180
+ aria-label={isCompact ? 'Expand header' : 'Collapse header'}
181
+ >
182
+ {isCompact ? '▼' : '▲'}
183
+ </button>
152
184
  </div>
153
185
  </TooltipProvider>
154
186
 
@@ -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">
@@ -71,7 +71,7 @@ export function BackgroundPanel(): React.ReactElement {
71
71
  if (tasks.length === 0) {
72
72
  return (
73
73
  <div className="background-panel empty" data-testid="background-panel">
74
- <div className="placeholder">No background tasks</div>
74
+ <div className="placeholder">No subagent tasks</div>
75
75
  </div>
76
76
  );
77
77
  }