repoburg 1.3.196 → 1.3.197

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 (71) hide show
  1. package/agent-pane/backend/data.db-shm +0 -0
  2. package/agent-pane/backend/data.db-wal +0 -0
  3. package/agent-pane/backend/dist/index.js +13399 -10390
  4. package/agent-pane/backend/package-lock.json +162 -92
  5. package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
  6. package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
  7. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
  8. package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
  9. package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
  10. package/agent-pane/backend/src/claude/claude-types.ts +16 -4
  11. package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
  12. package/agent-pane/backend/src/db/index.ts +102 -0
  13. package/agent-pane/backend/src/db/schema.ts +4 -0
  14. package/agent-pane/backend/src/index.ts +3 -1
  15. package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
  16. package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
  17. package/agent-pane/backend/src/integration/threads.test.ts +309 -0
  18. package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
  19. package/agent-pane/backend/src/routes/providers.ts +11 -0
  20. package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
  21. package/agent-pane/backend/src/routes/sessions.ts +133 -28
  22. package/agent-pane/backend/src/routes/threads.ts +308 -0
  23. package/agent-pane/backend/src/routes/turns.ts +32 -9
  24. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
  25. package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
  26. package/agent-pane/frontend/dist/index.html +2 -2
  27. package/agent-pane/frontend/package-lock.json +51 -9
  28. package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
  29. package/agent-pane/frontend/src/api/providers.ts +6 -1
  30. package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
  31. package/agent-pane/frontend/src/api/sessions.ts +13 -5
  32. package/agent-pane/frontend/src/api/websocket.ts +65 -1
  33. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
  34. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
  35. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
  36. package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
  37. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
  38. package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
  40. package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
  41. package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
  42. package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
  43. package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
  44. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
  45. package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
  46. package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
  47. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
  48. package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
  49. package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
  50. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
  51. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
  52. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
  53. package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
  54. package/agent-pane/frontend/src/store/store.test.ts +5 -5
  55. package/agent-pane/frontend/src/store/store.ts +4 -1
  56. package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
  57. package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
  58. package/agent-pane/frontend/src/styles/app.css +150 -19
  59. package/agent-pane/frontend/src/test/fixtures.ts +4 -0
  60. package/agent-pane/frontend/src/types/index.ts +5 -2
  61. package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
  62. package/agent-pane/frontend/vite.config.ts +2 -2
  63. package/agent-pane/shared/provider-types.ts +14 -0
  64. package/agent-pane/shared/types.ts +60 -0
  65. package/package.json +1 -1
  66. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
  67. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
  68. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
  69. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
  70. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
  71. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
@@ -1,12 +1,70 @@
1
1
  import { marked } from 'marked';
2
2
  import DOMPurify from 'dompurify';
3
3
  import { memo, useState, useEffect } from 'react';
4
- import { Layers, ChevronRight } from 'lucide-react';
5
- import type { Turn, Item } from '../types/index.ts';
4
+ import {
5
+ Layers, ChevronRight, Brain, Terminal, SatelliteDish, Bot, Cog,
6
+ FilePlus, Plug, Search, ListTodo, Minimize2, CircleAlert, Check, Square, FileText, X,
7
+ } from 'lucide-react';
8
+ import type { Turn, Item, DiscussionThread, Attachment } from '../types/index.ts';
9
+ import { runtimeIcon } from '../types/index.ts';
6
10
  import CollapsibleToolCalls, { isToolItem } from './CollapsibleToolCalls.tsx';
11
+ import CommentableText from './CommentableText.tsx';
7
12
 
8
13
  marked.setOptions({ breaks: true, gfm: true });
9
14
 
15
+ /** Compact inline attachment pills shown below the user prompt in the render area. */
16
+ function AttachmentPills({ attachments }: { attachments: Attachment[] }) {
17
+ const [previewSrc, setPreviewSrc] = useState<string | null>(null);
18
+
19
+ useEffect(() => {
20
+ if (!previewSrc) return;
21
+ function onKey(e: KeyboardEvent) {
22
+ if (e.key === 'Escape') setPreviewSrc(null);
23
+ }
24
+ window.addEventListener('keydown', onKey);
25
+ return () => window.removeEventListener('keydown', onKey);
26
+ }, [previewSrc]);
27
+
28
+ if (!Array.isArray(attachments) || attachments.length === 0) return null;
29
+
30
+ return (
31
+ <>
32
+ <div className="attachment-pills">
33
+ {attachments.map((att, i) => {
34
+ const isImage = att.media_type.startsWith('image/');
35
+ const src = `data:${att.media_type};base64,${att.data}`;
36
+ return (
37
+ <span
38
+ key={i}
39
+ className="attachment-pill"
40
+ title={att.name}
41
+ onClick={isImage ? () => setPreviewSrc(src) : undefined}
42
+ style={isImage ? { cursor: 'pointer' } : undefined}
43
+ >
44
+ {isImage ? (
45
+ <img src={src} alt={att.name} className="attachment-pill-thumb" />
46
+ ) : (
47
+ <FileText size={12} className="attachment-pill-icon" />
48
+ )}
49
+ <span className="attachment-pill-name">{att.name}</span>
50
+ </span>
51
+ );
52
+ })}
53
+ </div>
54
+ {previewSrc && (
55
+ <div className="image-preview-modal" onClick={() => setPreviewSrc(null)}>
56
+ <div className="image-preview-content" onClick={(e) => e.stopPropagation()}>
57
+ <img src={previewSrc} alt="preview" />
58
+ <button className="image-preview-close" onClick={() => setPreviewSrc(null)}>
59
+ <X size={20} />
60
+ </button>
61
+ </div>
62
+ </div>
63
+ )}
64
+ </>
65
+ );
66
+ }
67
+
10
68
  function renderMarkdown(content: string): string {
11
69
  const raw = marked.parse(content) as string;
12
70
  return DOMPurify.sanitize(raw);
@@ -28,20 +86,28 @@ function cleanCommand(cmd: string): string {
28
86
  const MAX_OUTPUT = 10_000;
29
87
 
30
88
  /** Render a single item as a turn card. */
31
- function ItemCard({ item }: { item: Item }) {
89
+ function ItemCard({ item, runtimeType, turn, threads }: { item: Item; runtimeType?: string; turn: Turn; threads: DiscussionThread[] }) {
32
90
  const isRunning = item.status === 'in_progress';
33
91
  const [showFullOutput, setShowFullOutput] = useState(false);
34
92
 
35
93
  switch (item.type) {
36
94
  case 'agent_message': {
95
+ const { iconClass, iconText } = runtimeIcon(runtimeType ?? 'codex');
37
96
  return (
38
97
  <div className="render-content">
39
98
  <div className="turn-header">
40
- <span className="turn-header-icon ic-codex">CX</span>
99
+ <span className={`turn-header-icon ${iconClass}`}>{iconText}</span>
41
100
  <span className="turn-header-title">Agent</span>
42
101
  {isRunning && <span className="turn-header-meta">writing…</span>}
43
102
  </div>
44
- <div dangerouslySetInnerHTML={{ __html: renderMarkdown(itemText(item) || '…') }} />
103
+ <CommentableText
104
+ itemId={item.id}
105
+ turnId={turn.id}
106
+ sessionId={turn.session_id}
107
+ content={itemText(item) || '…'}
108
+ threads={threads}
109
+ runtimeType={runtimeType}
110
+ />
45
111
  </div>
46
112
  );
47
113
  }
@@ -51,7 +117,7 @@ function ItemCard({ item }: { item: Item }) {
51
117
  <div className="render-content">
52
118
  <div className="turn-header">
53
119
  <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
54
- 💭
120
+ <Brain size={14} />
55
121
  </span>
56
122
  <span className="turn-header-title">Reasoning</span>
57
123
  </div>
@@ -73,7 +139,7 @@ function ItemCard({ item }: { item: Item }) {
73
139
  <div className="render-content">
74
140
  <div className="turn-header">
75
141
  <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
76
- 🔧
142
+ <Terminal size={14} />
77
143
  </span>
78
144
  <span className="turn-header-title">Command</span>
79
145
  {summary && <span className="turn-header-meta">{summary}</span>}
@@ -98,13 +164,14 @@ function ItemCard({ item }: { item: Item }) {
98
164
  }
99
165
 
100
166
  case 'file_change': {
101
- const changes = (item.data?.changes as Array<{ path: string; kind: string }>) || [];
167
+ const rawChanges = item.data?.changes;
168
+ const changes = Array.isArray(rawChanges) ? (rawChanges as Array<{ path: string; kind: string }>) : [];
102
169
  const summary = `${changes.length} file${changes.length !== 1 ? 's' : ''}`;
103
170
  return (
104
171
  <div className="render-content">
105
172
  <div className="turn-header">
106
173
  <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
107
- 📁
174
+ <FilePlus size={14} />
108
175
  </span>
109
176
  <span className="turn-header-title">File Changes</span>
110
177
  <span className="turn-header-meta">{summary}</span>
@@ -128,6 +195,24 @@ function ItemCard({ item }: { item: Item }) {
128
195
  case 'mcp_tool_call': {
129
196
  const data = item.data || {};
130
197
  const tool = (data.tool as string) || 'mcp';
198
+
199
+ // Compact status card for Agent calls (non-clickable — subagent view removed)
200
+ if (tool === 'Agent') {
201
+ const args = data.arguments as Record<string, unknown> | null;
202
+ const desc = (args?.description as string) || (args?.prompt as string) || 'Subagent';
203
+ const isFailed = item.status === 'failed';
204
+ const summary = isRunning ? 'running…' : isFailed ? 'failed' : 'done';
205
+ return (
206
+ <div className="agent-call-card">
207
+ <span className="agent-call-icon"><Bot size={14} /></span>
208
+ <span className="agent-call-desc">{desc}</span>
209
+ <span className={`agent-call-status ${isRunning ? 'running' : isFailed ? 'failed' : 'done'}`}>
210
+ {summary}
211
+ </span>
212
+ </div>
213
+ );
214
+ }
215
+
131
216
  const server = (data.server as string) || '';
132
217
  const args = data.arguments as Record<string, unknown> | null;
133
218
  const result = data.result as {
@@ -142,7 +227,7 @@ function ItemCard({ item }: { item: Item }) {
142
227
  <div className="render-content">
143
228
  <div className="turn-header">
144
229
  <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
145
- 🔌
230
+ <Plug size={14} />
146
231
  </span>
147
232
  <span className="turn-header-title">MCP: {tool}</span>
148
233
  {server && <span className="turn-header-meta">{server}</span>}
@@ -191,7 +276,7 @@ function ItemCard({ item }: { item: Item }) {
191
276
  <div className="render-content">
192
277
  <div className="turn-header">
193
278
  <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
194
- 🔍
279
+ <Search size={14} />
195
280
  </span>
196
281
  <span className="turn-header-title">Web Search</span>
197
282
  </div>
@@ -206,14 +291,14 @@ function ItemCard({ item }: { item: Item }) {
206
291
  <div className="render-content">
207
292
  <div className="turn-header">
208
293
  <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
209
- 📋
294
+ <ListTodo size={14} />
210
295
  </span>
211
296
  <span className="turn-header-title">Todo List</span>
212
297
  </div>
213
298
  <ul className="todo-list">
214
299
  {items.map((t, i) => (
215
300
  <li key={i} className={t.completed ? 'todo-done' : 'todo-pending'}>
216
- {t.completed ? '✅' : '⬜'} {t.text}
301
+ {t.completed ? <Check size={14} /> : <Square size={14} />} {t.text}
217
302
  </li>
218
303
  ))}
219
304
  </ul>
@@ -221,13 +306,73 @@ function ItemCard({ item }: { item: Item }) {
221
306
  );
222
307
  }
223
308
 
309
+ case 'background_task': {
310
+ const data = item.data || {};
311
+ const taskType = (data.task_type as string) || 'unknown';
312
+ const subagentType = (data.subagent_type as string) || null;
313
+ const desc = (data.description as string) || '';
314
+ const taskStatus = (data.status as string) || 'running';
315
+ const lastTool = (data.last_tool_name as string) || null;
316
+ const tokenCount = (data.token_count as number) || null;
317
+ const isSubagent = taskType === 'local_agent';
318
+ const isMonitor = taskType === 'local_bash';
319
+ const TaskIcon = isSubagent ? Bot : isMonitor ? SatelliteDish : Cog;
320
+ const typeLabel = isSubagent ? `Subagent${subagentType ? `: ${subagentType}` : ''}` : isMonitor ? 'Monitor' : 'Background Task';
321
+ const statusLabel = taskStatus === 'running' ? 'running…' : taskStatus === 'completed' ? 'done' : taskStatus === 'failed' ? 'failed' : taskStatus === 'stopped' ? 'stopped' : taskStatus;
322
+ const isFailed = item.status === 'failed' || taskStatus === 'failed';
323
+ return (
324
+ <div className="render-content">
325
+ <div className="turn-header">
326
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
327
+ <TaskIcon size={14} />
328
+ </span>
329
+ <span className="turn-header-title">{typeLabel}</span>
330
+ <span className="turn-header-meta" style={isFailed ? { color: 'var(--red)' } : undefined}>
331
+ {statusLabel}
332
+ </span>
333
+ </div>
334
+ {desc && <p className="user-message" style={{ fontSize: '13px' }}>{desc}</p>}
335
+ {lastTool && isRunning && (
336
+ <div className="cmd-output" style={{ color: 'var(--text-3)', fontSize: '12px' }}>
337
+ last tool: {lastTool}
338
+ {tokenCount != null && ` · ${tokenCount.toLocaleString()} tokens`}
339
+ </div>
340
+ )}
341
+ <div className="cmd-output" style={{ color: 'var(--text-muted)', fontSize: '11px' }}>
342
+ Output available in Tasks panel — click the status bar indicator or the Tasks tab.
343
+ </div>
344
+ </div>
345
+ );
346
+ }
347
+
348
+ case 'context_compaction': {
349
+ const trigger = (item.data?.trigger as string) ?? 'auto';
350
+ const pre = (item.data?.pre_tokens as number) ?? 0;
351
+ const post = (item.data?.post_tokens as number) ?? 0;
352
+ const reduction = pre > 0 ? Math.round((1 - post / pre) * 100) : 0;
353
+ const label = trigger === 'manual' ? 'Context compacted (/compact)' : 'Context auto-compacted';
354
+ return (
355
+ <div className="render-content">
356
+ <div className="turn-header">
357
+ <span className="turn-header-icon" style={{ background: 'var(--surface-2)', color: 'var(--text-3)' }}>
358
+ <Minimize2 size={14} />
359
+ </span>
360
+ <span className="turn-header-title">{label}</span>
361
+ </div>
362
+ <div className="cmd-output" style={{ color: 'var(--text-3)' }}>
363
+ {pre.toLocaleString()} → {post.toLocaleString()} tokens ({reduction}% reduction)
364
+ </div>
365
+ </div>
366
+ );
367
+ }
368
+
224
369
  case 'error': {
225
370
  const msg = (item.data?.message as string) || 'Unknown error';
226
371
  return (
227
372
  <div className="render-content">
228
373
  <div className="turn-header">
229
374
  <span className="turn-header-icon" style={{ background: 'var(--red)', color: '#fff' }}>
230
- !
375
+ <CircleAlert size={14} />
231
376
  </span>
232
377
  <span className="turn-header-title">Error</span>
233
378
  </div>
@@ -248,21 +393,28 @@ export { MemoItemCard };
248
393
 
249
394
  interface Props {
250
395
  turn: Turn;
396
+ runtimeType?: string;
397
+ threads?: DiscussionThread[];
251
398
  }
252
399
 
253
- function TurnContentBase({ turn }: Props) {
400
+ function TurnContentBase({ turn, runtimeType, threads = [] }: Props) {
254
401
  const [intermediateExpanded, setIntermediateExpanded] = useState(false);
255
402
 
256
- // Auto-expand Level 1 when turn is running/pending, auto-collapse when completed
403
+ // Auto-expand Level 1 when turn is running/pending, auto-collapse when completed.
404
+ // Guard: don't auto-collapse if there are in_progress background_task items —
405
+ // Monitor tasks may still be running after the turn's result/success arrives.
257
406
  useEffect(() => {
407
+ const hasRunningBgTask = turn.items.some(
408
+ (item) => item.type === 'background_task' && item.status === 'in_progress',
409
+ );
258
410
  if (turn.status === 'running' || turn.status === 'pending') {
259
411
  setIntermediateExpanded(true);
260
- } else if (turn.status === 'completed') {
412
+ } else if (turn.status === 'completed' && !hasRunningBgTask) {
261
413
  setIntermediateExpanded(false);
262
414
  }
263
- }, [turn.status]);
415
+ }, [turn.status, turn.items]);
264
416
 
265
- // Find the last agent_message item — that is the final response
417
+ // Find the last agent_message item FROM THE MAIN AGENT — that is the final response.
266
418
  let lastAgentIdx = -1;
267
419
  for (let i = turn.items.length - 1; i >= 0; i--) {
268
420
  if (turn.items[i].type === 'agent_message') {
@@ -275,6 +427,18 @@ function TurnContentBase({ turn }: Props) {
275
427
  // Intermediate = everything before final agent_message (or ALL items if running)
276
428
  const intermediateItems = hasFinalResponse ? turn.items.slice(0, lastAgentIdx) : turn.items;
277
429
 
430
+ // Filter out empty reasoning items (text is "" — signed-empty thinking blocks from Claude SDK).
431
+ // These inflate the intermediate step count and render as empty cards.
432
+ const visibleIntermediate = intermediateItems.filter(
433
+ (item) => !(item.type === 'reasoning' && (!item.data?.text || item.data.text === '')),
434
+ );
435
+
436
+ // Filter main flow: remove background_task items (they belong in Tasks panel).
437
+ const mainIntermediate = visibleIntermediate.filter((item) => {
438
+ if (item.type === 'background_task') return false;
439
+ return true;
440
+ });
441
+
278
442
  // Group intermediate items into segments by agent_message boundaries
279
443
  // Each segment: one agent_message + following tool/non-tool items until next agent_message
280
444
  interface Segment {
@@ -284,7 +448,7 @@ function TurnContentBase({ turn }: Props) {
284
448
  }
285
449
  const segments: Segment[] = [];
286
450
  let currentSeg: Segment | null = null;
287
- for (const item of intermediateItems) {
451
+ for (const item of mainIntermediate) {
288
452
  if (item.type === 'agent_message') {
289
453
  if (currentSeg) segments.push(currentSeg);
290
454
  currentSeg = { agentMessage: item, toolItems: [], otherItems: [] };
@@ -296,7 +460,11 @@ function TurnContentBase({ turn }: Props) {
296
460
  }
297
461
  if (currentSeg) segments.push(currentSeg);
298
462
 
299
- const intermediateCount = intermediateItems.length;
463
+ // Count only tool items for the collapsed header (reasoning is not a "step")
464
+ const toolItemCount = mainIntermediate.filter((item) => isToolItem(item)).length;
465
+ // Show intermediate section when there are tool calls or when turn is running/pending
466
+ // (so the user sees thinking activity). For completed turns with only reasoning, hide it.
467
+ const showIntermediate = toolItemCount > 0 || (mainIntermediate.length > 0 && (turn.status === 'running' || turn.status === 'pending'));
300
468
 
301
469
  return (
302
470
  <div className="turn-group">
@@ -309,17 +477,20 @@ function TurnContentBase({ turn }: Props) {
309
477
  <span className="turn-header-title">You</span>
310
478
  </div>
311
479
  <p className="user-message">{turn.user_prompt}</p>
480
+ {Array.isArray(turn.attachments) && turn.attachments.length > 0 && <AttachmentPills attachments={turn.attachments} />}
312
481
  </div>
313
482
 
314
483
  {/* Level 1: Intermediate steps — collapsible (auto-expand while running, auto-collapse on complete) */}
315
- {intermediateCount > 0 && (
484
+ {showIntermediate && (
316
485
  <div className="tool-calls">
317
486
  <div className="tool-calls-header" onClick={() => setIntermediateExpanded((e) => !e)}>
318
487
  <span className="tool-calls-icon">
319
488
  <Layers size={12} />
320
489
  </span>
321
490
  <span className="tool-calls-summary">
322
- {intermediateCount} intermediate step{intermediateCount > 1 ? 's' : ''}
491
+ {toolItemCount > 0
492
+ ? `${toolItemCount} tool call${toolItemCount > 1 ? 's' : ''}`
493
+ : 'thinking…'}
323
494
  </span>
324
495
  <ChevronRight
325
496
  size={14}
@@ -332,13 +503,13 @@ function TurnContentBase({ turn }: Props) {
332
503
  {segments.map((seg, i) => (
333
504
  <div key={i} className="intermediate-segment">
334
505
  {/* Agent message for this segment */}
335
- {seg.agentMessage && <MemoItemCard item={seg.agentMessage} />}
506
+ {seg.agentMessage && <MemoItemCard item={seg.agentMessage} runtimeType={runtimeType} turn={turn} threads={threads} />}
336
507
  {/* Non-tool items inline (reasoning, etc.) */}
337
508
  {seg.otherItems.map((item) => (
338
- <MemoItemCard key={item.id} item={item} />
509
+ <MemoItemCard key={item.id} item={item} runtimeType={runtimeType} turn={turn} threads={threads} />
339
510
  ))}
340
511
  {/* Level 2: Tool calls for this segment — collapsed by default */}
341
- {seg.toolItems.length > 0 && <CollapsibleToolCalls items={seg.toolItems} />}
512
+ {seg.toolItems.length > 0 && <CollapsibleToolCalls items={seg.toolItems} runtimeType={runtimeType} turn={turn} threads={threads} />}
342
513
  </div>
343
514
  ))}
344
515
  </div>
@@ -347,10 +518,10 @@ function TurnContentBase({ turn }: Props) {
347
518
  )}
348
519
 
349
520
  {/* Final agent message — always visible */}
350
- {hasFinalResponse && <MemoItemCard key={turn.items[lastAgentIdx].id} item={turn.items[lastAgentIdx]} />}
521
+ {hasFinalResponse && <MemoItemCard key={turn.items[lastAgentIdx].id} item={turn.items[lastAgentIdx]} runtimeType={runtimeType} turn={turn} threads={threads} />}
351
522
 
352
523
  {/* Items after final agent message (rare — errors, etc.) */}
353
- {hasFinalResponse && turn.items.slice(lastAgentIdx + 1).map((item) => <MemoItemCard key={item.id} item={item} />)}
524
+ {hasFinalResponse && turn.items.slice(lastAgentIdx + 1).map((item) => <MemoItemCard key={item.id} item={item} runtimeType={runtimeType} turn={turn} threads={threads} />)}
354
525
 
355
526
  {/* Pending state (no items at all) */}
356
527
  {turn.items.length === 0 && !turn.error && (turn.status === 'pending' || turn.status === 'running') && (
@@ -1,4 +1,4 @@
1
- import type { ProviderCapabilities } from '../../types/index.ts';
1
+ import type { ProviderCapabilities, ProviderSlashCommand } from '../../types/index.ts';
2
2
 
3
3
  /** A slash command definition. */
4
4
  export interface SlashCommand {
@@ -9,10 +9,13 @@ export interface SlashCommand {
9
9
  capability: keyof ProviderCapabilities | null;
10
10
  /** Sub-popup type to open when selected. */
11
11
  subPopup?: 'sandbox' | 'websearch' | 'network';
12
+ /** When true, selecting inserts `/<id> ` as text (provider-native commands
13
+ * like /compact, /clear that are handled by the runtime, not the UI). */
14
+ insertAsText?: boolean;
12
15
  }
13
16
 
14
- /** All available slash commands. */
15
- const ALL_COMMANDS: SlashCommand[] = [
17
+ /** All built-in slash commands. */
18
+ const BUILTIN_COMMANDS: SlashCommand[] = [
16
19
  { id: 'model', label: '/model', hint: 'Change model', capability: 'dynamicModels' },
17
20
  { id: 'sandbox', label: '/sandbox', hint: 'Sandbox mode', capability: 'sandbox', subPopup: 'sandbox' },
18
21
  { id: 'websearch', label: '/websearch', hint: 'Web search mode', capability: 'webSearch', subPopup: 'websearch' },
@@ -22,12 +25,28 @@ const ALL_COMMANDS: SlashCommand[] = [
22
25
  { id: 'mcp', label: '/mcp', hint: 'Browse MCP servers', capability: 'mcpServers' },
23
26
  ];
24
27
 
25
- /** Filter commands by capability and text filter. */
28
+ /** Map provider-native commands (fetched dynamically from the runtime) to SlashCommand. */
29
+ function mapProviderCommands(commands: ProviderSlashCommand[]): SlashCommand[] {
30
+ return commands.map((c) => ({
31
+ id: c.name,
32
+ label: `/${c.name}`,
33
+ hint: c.description,
34
+ capability: null,
35
+ insertAsText: true,
36
+ }));
37
+ }
38
+
39
+ /** Filter built-in and provider commands by capability and text filter. */
26
40
  export function filterCommands(
27
41
  filter: string,
28
42
  capabilities: ProviderCapabilities,
43
+ providerCommands: ProviderSlashCommand[] = [],
29
44
  ): SlashCommand[] {
30
- return ALL_COMMANDS.filter((cmd) => {
45
+ const all = [...BUILTIN_COMMANDS, ...mapProviderCommands(providerCommands)];
46
+ const seen = new Set<string>();
47
+ return all.filter((cmd) => {
48
+ if (seen.has(cmd.label)) return false;
49
+ seen.add(cmd.label);
31
50
  if (cmd.capability && !capabilities[cmd.capability]) return false;
32
51
  if (filter && !cmd.label.toLowerCase().includes(filter.toLowerCase())) return false;
33
52
  return true;
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { render, screen, cleanup } from '@testing-library/react';
3
+ import TasksPanel from './TasksPanel.tsx';
4
+ import { resetStore } from '../../test/component-helpers.ts';
5
+ import { makeDetail, makeTurn, makeItem } from '../../test/fixtures.ts';
6
+
7
+ describe('TasksPanel', () => {
8
+ beforeEach(() => {
9
+ resetStore();
10
+ });
11
+ afterEach(() => {
12
+ cleanup();
13
+ });
14
+
15
+ it('shows empty state when no background tasks', () => {
16
+ const detail = makeDetail('s1', []);
17
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
18
+ render(<TasksPanel />);
19
+ expect(screen.getByText('No background tasks yet')).toBeInTheDocument();
20
+ });
21
+
22
+ it('renders a Monitor task card with description', () => {
23
+ const item = makeItem('i1', 't1', 'background_task');
24
+ item.data = {
25
+ task_type: 'local_bash',
26
+ description: 'watch echo hello',
27
+ status: 'running',
28
+ output: 'hello\n',
29
+ };
30
+ item.status = 'in_progress';
31
+ const turn = makeTurn('t1', 's1', { items: [item] });
32
+ const detail = makeDetail('s1', [turn]);
33
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
34
+ render(<TasksPanel />);
35
+ expect(screen.getByText('Monitor')).toBeInTheDocument();
36
+ expect(screen.getByText('watch echo hello')).toBeInTheDocument();
37
+ // Output is in a <code> element inside .task-output
38
+ const outputEl = document.querySelector('.task-output code');
39
+ expect(outputEl?.textContent).toContain('hello');
40
+ });
41
+
42
+ it('does not show Subagent (local_agent) tasks in Tasks panel', () => {
43
+ const item = makeItem('i2', 't1', 'background_task');
44
+ item.data = {
45
+ task_type: 'local_agent',
46
+ subagent_type: 'explore-codebase',
47
+ description: 'explore the codebase',
48
+ status: 'completed',
49
+ };
50
+ item.status = 'completed';
51
+ const turn = makeTurn('t1', 's1', { items: [item] });
52
+ const detail = makeDetail('s1', [turn]);
53
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
54
+ render(<TasksPanel />);
55
+ // local_agent items are filtered out — they appear in subagent tabs, not Tasks panel
56
+ expect(screen.getByText('No background tasks yet')).toBeInTheDocument();
57
+ });
58
+
59
+ it('sorts running tasks before completed', () => {
60
+ const runningItem = makeItem('i1', 't2', 'background_task');
61
+ runningItem.data = { task_type: 'local_bash', description: 'running task', status: 'running' };
62
+ runningItem.status = 'in_progress';
63
+
64
+ const doneItem = makeItem('i2', 't1', 'background_task');
65
+ doneItem.data = { task_type: 'local_bash', description: 'done task', status: 'completed' };
66
+ doneItem.status = 'completed';
67
+
68
+ const turn1 = makeTurn('t1', 's1', { items: [doneItem] });
69
+ const turn2 = makeTurn('t2', 's1', { items: [runningItem] });
70
+ const detail = makeDetail('s1', [turn1, turn2]);
71
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
72
+ render(<TasksPanel />);
73
+
74
+ const cards = document.querySelectorAll('.task-card');
75
+ expect(cards.length).toBe(2);
76
+ // Running task should be first
77
+ expect(cards[0].textContent).toContain('running task');
78
+ expect(cards[1].textContent).toContain('done task');
79
+ });
80
+
81
+ it('shows task count in section title (local_agent excluded)', () => {
82
+ const item1 = makeItem('i1', 't1', 'background_task');
83
+ item1.data = { task_type: 'local_bash', description: 'task 1', status: 'completed' };
84
+ item1.status = 'completed';
85
+
86
+ const item2 = makeItem('i2', 't1', 'background_task');
87
+ item2.data = { task_type: 'local_agent', description: 'task 2', status: 'completed' };
88
+ item2.status = 'completed';
89
+
90
+ const turn = makeTurn('t1', 's1', { items: [item1, item2] });
91
+ const detail = makeDetail('s1', [turn]);
92
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
93
+ render(<TasksPanel />);
94
+ // Only local_bash counted — local_agent filtered out
95
+ expect(screen.getByText(/Background Tasks \(1\)/)).toBeInTheDocument();
96
+ });
97
+ });