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
@@ -0,0 +1,291 @@
1
+ import { useEffect, useRef, useState, useCallback } from 'react';
2
+ import { marked } from 'marked';
3
+ import DOMPurify from 'dompurify';
4
+ import { MessageSquarePlus, X, Send, Quote } from 'lucide-react';
5
+ import type { DiscussionThread } from '../types/index.ts';
6
+ import { useStore } from '../store/store.ts';
7
+
8
+ marked.setOptions({ breaks: true, gfm: true });
9
+
10
+ function renderMarkdown(content: string): string {
11
+ const raw = marked.parse(content) as string;
12
+ return DOMPurify.sanitize(raw);
13
+ }
14
+
15
+ /** Find the text node and offset within it that corresponds to a character offset in the container's textContent. */
16
+ function findTextNode(
17
+ container: HTMLElement,
18
+ charOffset: number,
19
+ ): { node: Text; offset: number } | null {
20
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
21
+ let count = 0;
22
+ while (walker.nextNode()) {
23
+ const node = walker.currentNode as Text;
24
+ const len = node.textContent?.length ?? 0;
25
+ if (count + len > charOffset) {
26
+ return { node, offset: charOffset - count };
27
+ }
28
+ count += len;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** Highlight all thread anchors in the container by wrapping selected text in <mark> elements. */
34
+ function applyHighlights(container: HTMLElement, threads: DiscussionThread[]): void {
35
+ // Remove existing marks (unwrap them back to plain text)
36
+ container.querySelectorAll('mark[data-thread-id]').forEach((mark) => {
37
+ const parent = mark.parentNode;
38
+ if (!parent) return;
39
+ const text = document.createTextNode(mark.textContent || '');
40
+ parent.replaceChild(text, mark);
41
+ parent.normalize();
42
+ });
43
+
44
+ for (const thread of threads) {
45
+ const start = findTextNode(container, thread.start_offset);
46
+ const end = findTextNode(container, thread.end_offset);
47
+ if (!start || !end) continue;
48
+
49
+ const range = document.createRange();
50
+ range.setStart(start.node, start.offset);
51
+ range.setEnd(end.node, end.offset);
52
+
53
+ const mark = document.createElement('mark');
54
+ mark.setAttribute('data-thread-id', thread.id);
55
+ if (thread.status === 'resolved') mark.classList.add('thread-mark-resolved');
56
+
57
+ try {
58
+ range.surroundContents(mark);
59
+ } catch {
60
+ // Range spans element boundaries — extract and wrap
61
+ const fragment = range.extractContents();
62
+ mark.appendChild(fragment);
63
+ range.insertNode(mark);
64
+ }
65
+ }
66
+ }
67
+
68
+ /** Compute character offsets of the current selection within the container's textContent. */
69
+ function getSelectionOffsets(container: HTMLElement): { start: number; end: number; text: string } | null {
70
+ const sel = window.getSelection();
71
+ if (!sel || sel.rangeCount === 0) return null;
72
+ const range = sel.getRangeAt(0);
73
+ if (range.collapsed) return null;
74
+ if (!container.contains(range.commonAncestorContainer)) return null;
75
+
76
+ const preRange = document.createRange();
77
+ preRange.setStart(container, 0);
78
+ preRange.setEnd(range.startContainer, range.startOffset);
79
+ const start = preRange.toString().length;
80
+ const text = range.toString();
81
+ return { start, end: start + text.length, text };
82
+ }
83
+
84
+ interface Props {
85
+ itemId: string;
86
+ turnId: string;
87
+ sessionId: string;
88
+ content: string;
89
+ threads: DiscussionThread[];
90
+ runtimeType?: string;
91
+ }
92
+
93
+ export default function CommentableText({ itemId, turnId, sessionId, content, threads }: Props) {
94
+ const containerRef = useRef<HTMLDivElement>(null);
95
+ const wrapperRef = useRef<HTMLDivElement>(null);
96
+ const [showDiscussBtn, setShowDiscussBtn] = useState(false);
97
+ const [btnPos, setBtnPos] = useState({ x: 0, y: 0 });
98
+ const [showPopover, setShowPopover] = useState(false);
99
+ const [popoverPos, setPopoverPos] = useState({ x: 0, y: 0 });
100
+ const [message, setMessage] = useState('');
101
+ const [selectionInfo, setSelectionInfo] = useState<{ start: number; end: number; text: string } | null>(null);
102
+ const { createThread, switchRightTab } = useStore();
103
+
104
+ // Handle Quote button click — populate command input with selected text
105
+ const handleQuoteClick = useCallback(() => {
106
+ if (!selectionInfo) return;
107
+ const formatted = `\n\`\`\`QUOTE\n${selectionInfo.text}\n\`\`\`\n\n`;
108
+ useStore.setState({ quotedText: formatted });
109
+ setShowDiscussBtn(false);
110
+ setSelectionInfo(null);
111
+ window.getSelection()?.removeAllRanges();
112
+ }, [selectionInfo]);
113
+ const threadsForItem = threads.filter((t) => t.item_id === itemId);
114
+
115
+ // Render markdown + apply highlights after each render
116
+ useEffect(() => {
117
+ if (containerRef.current) {
118
+ applyHighlights(containerRef.current, threadsForItem);
119
+ }
120
+ });
121
+
122
+ // Dismiss button + popover when clicking outside the wrapper
123
+ useEffect(() => {
124
+ function handleGlobalMouseDown(e: MouseEvent) {
125
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
126
+ setShowDiscussBtn(false);
127
+ setShowPopover(false);
128
+ }
129
+ }
130
+ document.addEventListener('mousedown', handleGlobalMouseDown);
131
+ return () => document.removeEventListener('mousedown', handleGlobalMouseDown);
132
+ }, []);
133
+
134
+ // Handle mousedown in container — hide everything (new selection starting or click)
135
+ const handleMouseDown = useCallback(() => {
136
+ setShowDiscussBtn(false);
137
+ setShowPopover(false);
138
+ }, []);
139
+
140
+ // Handle mouseup — check for text selection
141
+ const handleMouseUp = useCallback(() => {
142
+ if (showPopover) return; // don't interfere while typing
143
+ const container = containerRef.current;
144
+ if (!container) return;
145
+
146
+ const result = getSelectionOffsets(container);
147
+ if (result && result.text.trim().length > 0) {
148
+ const sel = window.getSelection();
149
+ if (sel && sel.rangeCount > 0) {
150
+ // Use viewport coordinates (position:fixed) — avoids overflow clipping
151
+ // and stacking-context issues from nested containers.
152
+ const selRect = sel.getRangeAt(0).getBoundingClientRect();
153
+ const BUTTON_HEIGHT = 28;
154
+ const GAP = 6;
155
+ let btnY = selRect.top - BUTTON_HEIGHT - GAP;
156
+ if (btnY < 8) {
157
+ btnY = selRect.bottom + GAP;
158
+ }
159
+ setBtnPos({
160
+ x: selRect.right,
161
+ y: btnY,
162
+ });
163
+ setShowDiscussBtn(true);
164
+ setSelectionInfo(result);
165
+ }
166
+ } else {
167
+ setShowDiscussBtn(false);
168
+ }
169
+ }, [showPopover]);
170
+
171
+ // Handle Discuss button click
172
+ const handleDiscussClick = useCallback(() => {
173
+ setShowDiscussBtn(false);
174
+ setShowPopover(true);
175
+ // Position popover where the button was — button is hidden, popover takes its place
176
+ setPopoverPos({ x: btnPos.x, y: btnPos.y });
177
+ }, [btnPos]);
178
+
179
+ // Handle submit — create thread
180
+ const handleSubmit = useCallback(async () => {
181
+ if (!selectionInfo || !message.trim()) return;
182
+ await createThread(sessionId, {
183
+ itemId,
184
+ turnId,
185
+ startOffset: selectionInfo.start,
186
+ endOffset: selectionInfo.end,
187
+ selectedText: selectionInfo.text,
188
+ message: message.trim(),
189
+ });
190
+ setMessage('');
191
+ setShowPopover(false);
192
+ setSelectionInfo(null);
193
+ // Clear selection
194
+ window.getSelection()?.removeAllRanges();
195
+ // Switch to Threads tab
196
+ switchRightTab('threads');
197
+ }, [selectionInfo, message, createThread, sessionId, itemId, turnId, switchRightTab]);
198
+
199
+ // Handle mark click — navigate to Threads tab
200
+ const handleMarkClick = useCallback(
201
+ (e: React.MouseEvent) => {
202
+ const target = e.target as HTMLElement;
203
+ if (target.tagName === 'MARK' && target.hasAttribute('data-thread-id')) {
204
+ e.preventDefault();
205
+ switchRightTab('threads');
206
+ const threadId = target.getAttribute('data-thread-id');
207
+ // Scroll to thread card after a brief delay
208
+ setTimeout(() => {
209
+ const card = document.getElementById(`thread-${threadId}`);
210
+ if (card) {
211
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
212
+ card.classList.add('thread-card-highlighted');
213
+ setTimeout(() => card.classList.remove('thread-card-highlighted'), 1500);
214
+ }
215
+ }, 100);
216
+ }
217
+ },
218
+ [switchRightTab],
219
+ );
220
+
221
+ return (
222
+ <div style={{ position: 'relative' }} ref={wrapperRef}>
223
+ <div
224
+ ref={containerRef}
225
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(content || '…') }}
226
+ onMouseDown={handleMouseDown}
227
+ onMouseUp={handleMouseUp}
228
+ onClick={handleMarkClick}
229
+ />
230
+
231
+ {/* Floating "Discuss" + "Quote" buttons */}
232
+ {showDiscussBtn && !showPopover && (
233
+ <div className="selection-actions" style={{ left: btnPos.x, top: btnPos.y }}>
234
+ <button
235
+ className="discuss-button"
236
+ onClick={handleDiscussClick}
237
+ title="Start a discussion about this text"
238
+ >
239
+ <MessageSquarePlus size={14} />
240
+ <span>Discuss</span>
241
+ </button>
242
+ <button
243
+ className="discuss-button"
244
+ onClick={handleQuoteClick}
245
+ title="Quote this text in your message"
246
+ >
247
+ <Quote size={14} />
248
+ <span>Quote</span>
249
+ </button>
250
+ </div>
251
+ )}
252
+
253
+ {/* Thread creation popover */}
254
+ {showPopover && (
255
+ <div className="thread-create-popover" style={{ left: popoverPos.x, top: popoverPos.y }}>
256
+ <div className="thread-create-header">
257
+ <span className="thread-create-quote">"{selectionInfo?.text.slice(0, 80)}{selectionInfo && selectionInfo.text.length > 80 ? '…' : ''}"</span>
258
+ <button className="thread-create-close" onClick={() => { setShowPopover(false); setSelectionInfo(null); }}>
259
+ <X size={14} />
260
+ </button>
261
+ </div>
262
+ <textarea
263
+ className="thread-create-input"
264
+ placeholder="Ask about this text…"
265
+ value={message}
266
+ onChange={(e) => setMessage(e.target.value)}
267
+ autoFocus
268
+ rows={2}
269
+ onKeyDown={(e) => {
270
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
271
+ e.preventDefault();
272
+ handleSubmit();
273
+ }
274
+ if (e.key === 'Escape') {
275
+ setShowPopover(false);
276
+ setSelectionInfo(null);
277
+ }
278
+ }}
279
+ />
280
+ <div className="thread-create-footer">
281
+ <span className="thread-create-hint">⌘↵ to send · Esc to cancel</span>
282
+ <button className="thread-create-send" onClick={handleSubmit} disabled={!message.trim()}>
283
+ <Send size={13} />
284
+ <span>Send</span>
285
+ </button>
286
+ </div>
287
+ </div>
288
+ )}
289
+ </div>
290
+ );
291
+ }
@@ -20,6 +20,13 @@ function RenderAreaBase() {
20
20
  const turns = detail?.turns ?? [];
21
21
  const renderScrollRef = useRef<HTMLDivElement>(null);
22
22
 
23
+ // Auto-scroll to bottom when session changes or new turns are added
24
+ useEffect(() => {
25
+ if (renderScrollRef.current) {
26
+ renderScrollRef.current.scrollTop = renderScrollRef.current.scrollHeight;
27
+ }
28
+ }, [activeSessionId, turns.length]);
29
+
23
30
  // F4: Scroll to selected change path when it changes
24
31
  useEffect(() => {
25
32
  if (!selectedChangePath || activeRightTab !== 'changes') return;
@@ -150,9 +157,9 @@ function RenderAreaBase() {
150
157
  <div className="empty-state">No turns yet. Send a message below to start.</div>
151
158
  )}
152
159
 
153
- {turns.map((turn, idx) => (
160
+ {turns.filter((t) => !t.thread_id).map((turn, idx) => (
154
161
  <div key={turn.id} id={`turn-${idx}`} className="turn-block">
155
- <TurnContent turn={turn} />
162
+ <TurnContent turn={turn} runtimeType={detail.runtime_type} threads={detail.threads ?? []} />
156
163
  </div>
157
164
  ))}
158
165
  </div>
@@ -14,16 +14,16 @@ describe('RightPanel', () => {
14
14
  it('renders 4 tab buttons', () => {
15
15
  render(<RightPanel />);
16
16
  expect(screen.getByText('Turns')).toBeInTheDocument();
17
- expect(screen.getByText('Metrics')).toBeInTheDocument();
18
17
  expect(screen.getByText('Changes')).toBeInTheDocument();
19
- expect(screen.getByText('Logs')).toBeInTheDocument();
18
+ expect(screen.getByText('Tasks')).toBeInTheDocument();
19
+ expect(screen.getByText('Threads')).toBeInTheDocument();
20
20
  });
21
21
 
22
22
  it('active tab has active class', () => {
23
- resetStore({ activeRightTab: 'metrics' });
23
+ resetStore({ activeRightTab: 'changes' });
24
24
  render(<RightPanel />);
25
- const metricsTab = screen.getByText('Metrics').closest('.right-tab');
26
- expect(metricsTab).toHaveClass('active');
25
+ const changesTab = screen.getByText('Changes').closest('.right-tab');
26
+ expect(changesTab).toHaveClass('active');
27
27
  });
28
28
 
29
29
  it('click on tab calls switchRightTab', () => {
@@ -1,40 +1,46 @@
1
1
  import { useStore } from '../store/store.ts';
2
2
  import TurnsPanel from './panels/TurnsPanel.tsx';
3
- import MetricsPanel from './panels/MetricsPanel.tsx';
4
3
  import ChangesPanel from './panels/ChangesPanel.tsx';
5
- import LogsPanel from './panels/LogsPanel.tsx';
4
+ import TasksPanel from './panels/TasksPanel.tsx';
5
+ import ThreadsPanel from './panels/ThreadsPanel.tsx';
6
6
  import type { RightTab } from '../types/index.ts';
7
7
 
8
- const TABS: { id: RightTab; label: string }[] = [
9
- { id: 'turns', label: 'Turns' },
10
- { id: 'metrics', label: 'Metrics' },
11
- { id: 'changes', label: 'Changes' },
12
- { id: 'logs', label: 'Logs' },
13
- ];
14
-
15
8
  export default function RightPanel() {
16
- const { activeRightTab, switchRightTab } = useStore();
9
+ const { activeRightTab, switchRightTab, activeSessionId, sessionDetails } = useStore();
10
+
11
+ // Badge: active background task count for the Tasks tab
12
+ const detail = activeSessionId ? sessionDetails[activeSessionId] : null;
13
+ const activeTaskCount = detail?.backgroundTasks?.length ?? 0;
14
+ const openThreadCount = detail?.threads?.filter((t) => t.status === 'open').length ?? 0;
15
+
16
+ const tabs: { id: RightTab; label: string; badge?: number }[] = [
17
+ { id: 'turns', label: 'Turns' },
18
+ { id: 'changes', label: 'Changes' },
19
+ { id: 'tasks', label: 'Tasks', badge: activeTaskCount || undefined },
20
+ { id: 'threads', label: 'Threads', badge: openThreadCount || undefined },
21
+ ];
17
22
 
18
23
  return (
19
24
  <div className="ide-right">
20
25
  <div className="right-tabs">
21
- {TABS.map((tab) => (
26
+ {tabs.map((tab) => (
22
27
  <div
23
28
  key={tab.id}
24
29
  className={`right-tab ${activeRightTab === tab.id ? 'active' : ''}`}
25
30
  onClick={() => switchRightTab(tab.id)}
26
31
  >
27
32
  {tab.label}
33
+ {tab.badge ? <span className="right-tab-badge">{tab.badge}</span> : null}
28
34
  </div>
29
35
  ))}
30
36
  </div>
31
37
 
32
38
  <div className="right-content">
33
39
  {activeRightTab === 'turns' && <TurnsPanel />}
34
- {activeRightTab === 'metrics' && <MetricsPanel />}
35
40
  {activeRightTab === 'changes' && <ChangesPanel />}
36
- {activeRightTab === 'logs' && <LogsPanel />}
41
+ {activeRightTab === 'tasks' && <TasksPanel />}
42
+ {activeRightTab === 'threads' && <ThreadsPanel />}
37
43
  </div>
38
44
  </div>
39
45
  );
40
- }
46
+ }
@@ -20,7 +20,11 @@ export default function SessionTabs() {
20
20
  const status = deriveSessionStatus(s);
21
21
  const isActive = activeSessionId === sid;
22
22
  return (
23
- <div key={sid} className={`ide-tab ${isActive ? 'active' : ''}`} onClick={() => selectSession(sid)}>
23
+ <div
24
+ key={sid}
25
+ className={`ide-tab ${isActive ? 'active' : ''}`}
26
+ onClick={() => selectSession(sid)}
27
+ >
24
28
  <RuntimeIcon iconClass={status.iconClass} iconText={status.iconText} size={14} />
25
29
  {s.title || sid.slice(0, 8)}
26
30
  {status.dot && <StatusDot status={status.dot} />}
@@ -36,9 +40,10 @@ export default function SessionTabs() {
36
40
  </div>
37
41
  );
38
42
  })}
43
+
39
44
  <div className="ide-tab-add" title="New Session" onClick={() => setShowNewSessionDialog(true)}>
40
45
  <Plus size={14} />
41
46
  </div>
42
47
  </div>
43
48
  );
44
- }
49
+ }
@@ -3,7 +3,15 @@ import StatusDot from './StatusDot.tsx';
3
3
  import ModelSelector from './ModelSelector.tsx';
4
4
  import { deriveSessionStatus } from '../hooks/useSessionStatus.ts';
5
5
  import { calcSessionDuration } from '../utils/duration.ts';
6
- import { Zap, Command } from 'lucide-react';
6
+ import { Zap, Command, Terminal } from 'lucide-react';
7
+ import type { ContextUsage } from '../types/index.ts';
8
+
9
+ /** Format a token count as compact K/M (e.g. 30000 → "30K", 1500000 → "1.5M"). */
10
+ function formatTokens(n: number): string {
11
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
12
+ if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
13
+ return String(n);
14
+ }
7
15
 
8
16
  export default function StatusBar() {
9
17
  const activeSessionId = useStore((s) => s.activeSessionId);
@@ -12,6 +20,8 @@ export default function StatusBar() {
12
20
  );
13
21
  const detail = useStore((s) => (activeSessionId ? (s.sessionDetails[activeSessionId] ?? null) : null));
14
22
  const wsConnected = useStore((s) => s.wsConnected);
23
+ const switchRightTab = useStore((s) => s.switchRightTab);
24
+ const contextUsage = detail?.contextUsage ?? null;
15
25
 
16
26
  if (!session) {
17
27
  return (
@@ -35,6 +45,32 @@ export default function StatusBar() {
35
45
  const status = deriveSessionStatus(session);
36
46
  const duration = calcSessionDuration(detail, status.isRunning);
37
47
 
48
+ // Count active background tasks from two sources:
49
+ // 1. backgroundTasks (from background_tasks_changed WS event — Monitor only)
50
+ // 2. in_progress background_task items in turns (from item:started/updated — both Monitor & Subagent)
51
+ // Deduplicate by task_id.
52
+ const bgTasks = detail?.backgroundTasks ?? [];
53
+ const activeTaskIds = new Set<string>();
54
+ const activeTaskTypes = new Map<string, string>();
55
+
56
+ for (const t of bgTasks) {
57
+ activeTaskIds.add(t.task_id);
58
+ activeTaskTypes.set(t.task_id, t.task_type);
59
+ }
60
+
61
+ for (const turn of detail?.turns ?? []) {
62
+ for (const item of turn.items) {
63
+ if (item.type === 'background_task' && item.status === 'in_progress') {
64
+ const taskId = (item.data?.task_id as string) ?? item.external_item_id;
65
+ const taskType = (item.data?.task_type as string) ?? 'unknown';
66
+ activeTaskIds.add(taskId);
67
+ activeTaskTypes.set(taskId, taskType);
68
+ }
69
+ }
70
+ }
71
+
72
+ const monitorCount = [...activeTaskTypes.values()].filter((t) => t === 'local_bash').length;
73
+
38
74
  return (
39
75
  <div className="ide-statusbar">
40
76
  <div className="sb-item">
@@ -42,6 +78,19 @@ export default function StatusBar() {
42
78
  </div>
43
79
  <div className="sb-divider">|</div>
44
80
  <ModelSelector />
81
+ {monitorCount > 0 && (
82
+ <>
83
+ <div className="sb-divider">|</div>
84
+ <div
85
+ className="sb-item"
86
+ style={{ color: 'var(--amber)', cursor: 'pointer' }}
87
+ title={`${monitorCount} Monitor task${monitorCount > 1 ? 's' : ''} running — click to view`}
88
+ onClick={() => switchRightTab('tasks')}
89
+ >
90
+ <Terminal size={14} /> {monitorCount}
91
+ </div>
92
+ </>
93
+ )}
45
94
  <div className="sb-spacer" />
46
95
  <div className="sb-item" title={wsConnected ? 'WebSocket connected' : 'WebSocket disconnected'}>
47
96
  <span className={`status-dot ${wsConnected ? 'done' : 'error'}`} />
@@ -51,6 +100,7 @@ export default function StatusBar() {
51
100
  <div className="sb-item" style={session.status === 'failed' ? { color: 'var(--red)' } : undefined}>
52
101
  <StatusDot status={status.dot} /> {status.label}
53
102
  </div>
103
+ {contextUsage && <ContextBar usage={contextUsage} />}
54
104
  <div className="sb-divider">|</div>
55
105
  <div className="sb-item">{duration}</div>
56
106
  <div className="sb-divider">|</div>
@@ -60,3 +110,20 @@ export default function StatusBar() {
60
110
  </div>
61
111
  );
62
112
  }
113
+
114
+ /** Compact context usage text — "%40 / 400K / 1M" format, hover tooltip. */
115
+ function ContextBar({ usage }: { usage: ContextUsage }) {
116
+ const colorVar = usage.percentage >= 85 ? 'var(--red)' : usage.percentage >= 60 ? 'var(--amber)' : 'var(--green)';
117
+ const tooltip = `${formatTokens(usage.totalTokens)} / ${formatTokens(usage.contextWindow)} tokens`;
118
+
119
+ return (
120
+ <>
121
+ <div className="sb-divider">|</div>
122
+ <div className="sb-context" title={tooltip}>
123
+ <span className="sb-context-text" style={{ color: colorVar }}>
124
+ {Math.round(usage.percentage)}% / {formatTokens(usage.totalTokens)} / {formatTokens(usage.contextWindow)}
125
+ </span>
126
+ </div>
127
+ </>
128
+ );
129
+ }
@@ -40,6 +40,16 @@ describe('TurnContent', () => {
40
40
  expect(screen.getByText('world').tagName).toBe('STRONG');
41
41
  });
42
42
 
43
+ it('renders Claude icon (CL / ic-claude) when runtimeType is "claude"', () => {
44
+ const item = makeItem('i1', 't1', 'agent_message');
45
+ item.data = { text: 'Hello' };
46
+ const turn = turnWithItems([item], { agent_response: 'Hello' });
47
+ render(<TurnContent turn={turn} runtimeType="claude" />);
48
+ const icon = document.querySelector('.ic-claude');
49
+ expect(icon).toBeTruthy();
50
+ expect(icon?.textContent).toBe('CL');
51
+ });
52
+
43
53
  it('renders reasoning item with "Reasoning" header', () => {
44
54
  const item = makeItem('i1', 't1', 'reasoning');
45
55
  item.data = { text: 'Thinking about it' };
@@ -107,41 +117,42 @@ describe('TurnContent', () => {
107
117
  });
108
118
 
109
119
  it('auto-expands intermediate steps when turn status is running', () => {
110
- const reasoning = makeItem('i1', 't1', 'reasoning');
111
- reasoning.data = { text: 'Thinking' };
120
+ const cmd = makeItem('i1', 't1', 'command_execution');
121
+ cmd.data = { command: 'ls', aggregated_output: 'output', exit_code: 0 };
112
122
  const agentMsg = makeItem('i2', 't1', 'agent_message');
113
123
  agentMsg.data = { text: 'Done' };
114
- // Reasoning is intermediate (before final agent_message)
115
- const turn = turnWithItems([reasoning, agentMsg], { status: 'running', agent_response: 'Done' });
124
+ // Command is intermediate (before final agent_message)
125
+ const turn = turnWithItems([cmd, agentMsg], { status: 'running', agent_response: 'Done' });
116
126
  render(<TurnContent turn={turn} />);
117
- // Reasoning is a non-tool item — visible when intermediate expanded
118
- expect(screen.getByText('Reasoning')).toBeInTheDocument();
127
+ // Auto-expanded — Level 2 nested tool-calls header (wrench icon) visible
128
+ expect(document.querySelectorAll('.lucide-wrench').length).toBe(1);
119
129
  });
120
130
 
121
131
  it('auto-collapses intermediate steps when turn status is completed', () => {
122
- const reasoning = makeItem('i1', 't1', 'reasoning');
123
- reasoning.data = { text: 'Thinking' };
132
+ const cmd = makeItem('i1', 't1', 'command_execution');
133
+ cmd.data = { command: 'ls', aggregated_output: 'out', exit_code: 0 };
124
134
  const agentMsg = makeItem('i2', 't1', 'agent_message');
125
135
  agentMsg.data = { text: 'Done' };
126
- const turn = turnWithItems([reasoning, agentMsg], { status: 'completed', agent_response: 'Done' });
136
+ const turn = turnWithItems([cmd, agentMsg], { status: 'completed', agent_response: 'Done' });
127
137
  render(<TurnContent turn={turn} />);
128
- // Collapsed — reasoning header not visible
129
- expect(screen.queryByText('Reasoning')).not.toBeInTheDocument();
138
+ // Collapsed — command header not visible
139
+ expect(screen.queryByText('Command')).not.toBeInTheDocument();
130
140
  });
131
141
 
132
142
  it('intermediate steps header click toggles expand', () => {
133
- const reasoning = makeItem('i1', 't1', 'reasoning');
134
- reasoning.data = { text: 'Thinking' };
143
+ const cmd = makeItem('i1', 't1', 'command_execution');
144
+ cmd.data = { command: 'ls', aggregated_output: 'output', exit_code: 0 };
135
145
  const agentMsg = makeItem('i2', 't1', 'agent_message');
136
146
  agentMsg.data = { text: 'Done' };
137
- const turn = turnWithItems([reasoning, agentMsg], { status: 'completed', agent_response: 'Done' });
147
+ const turn = turnWithItems([cmd, agentMsg], { status: 'completed', agent_response: 'Done' });
138
148
  render(<TurnContent turn={turn} />);
139
- // Initially collapsed
140
- expect(screen.queryByText('Reasoning')).not.toBeInTheDocument();
141
- // Click to expand
149
+ // Initially collapsed — Level 2 nested header (wrench icon) not visible
150
+ expect(document.querySelectorAll('.lucide-wrench').length).toBe(0);
151
+ // Click Level 1 header to expand
142
152
  const header = document.querySelector('.tool-calls-header')!;
143
153
  fireEvent.click(header);
144
- expect(screen.getByText('Reasoning')).toBeInTheDocument();
154
+ // Level 2 header now visible
155
+ expect(document.querySelectorAll('.lucide-wrench').length).toBe(1);
145
156
  });
146
157
 
147
158
  it('shows "Waiting to start…" for pending turn with no items', () => {