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.
- package/agent-pane/backend/data.db-shm +0 -0
- package/agent-pane/backend/data.db-wal +0 -0
- package/agent-pane/backend/dist/index.js +13399 -10390
- package/agent-pane/backend/package-lock.json +162 -92
- package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
- package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
- package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
- package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
- package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
- package/agent-pane/backend/src/claude/claude-types.ts +16 -4
- package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
- package/agent-pane/backend/src/db/index.ts +102 -0
- package/agent-pane/backend/src/db/schema.ts +4 -0
- package/agent-pane/backend/src/index.ts +3 -1
- package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
- package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
- package/agent-pane/backend/src/integration/threads.test.ts +309 -0
- package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
- package/agent-pane/backend/src/routes/providers.ts +11 -0
- package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
- package/agent-pane/backend/src/routes/sessions.ts +133 -28
- package/agent-pane/backend/src/routes/threads.ts +308 -0
- package/agent-pane/backend/src/routes/turns.ts +32 -9
- package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
- package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/package-lock.json +51 -9
- package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
- package/agent-pane/frontend/src/api/providers.ts +6 -1
- package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
- package/agent-pane/frontend/src/api/sessions.ts +13 -5
- package/agent-pane/frontend/src/api/websocket.ts +65 -1
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
- package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
- package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
- package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
- package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
- package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
- package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
- package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
- package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
- package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
- package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
- package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
- package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
- package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
- package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
- package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
- package/agent-pane/frontend/src/store/store.test.ts +5 -5
- package/agent-pane/frontend/src/store/store.ts +4 -1
- package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
- package/agent-pane/frontend/src/styles/app.css +150 -19
- package/agent-pane/frontend/src/test/fixtures.ts +4 -0
- package/agent-pane/frontend/src/types/index.ts +5 -2
- package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
- package/agent-pane/frontend/vite.config.ts +2 -2
- package/agent-pane/shared/provider-types.ts +14 -0
- package/agent-pane/shared/types.ts +60 -0
- package/package.json +1 -1
- package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
- package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
- package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
- package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -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('
|
|
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: '
|
|
23
|
+
resetStore({ activeRightTab: 'changes' });
|
|
24
24
|
render(<RightPanel />);
|
|
25
|
-
const
|
|
26
|
-
expect(
|
|
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
|
|
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
|
-
{
|
|
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 === '
|
|
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
|
|
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
|
|
111
|
-
|
|
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
|
-
//
|
|
115
|
-
const turn = turnWithItems([
|
|
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
|
-
//
|
|
118
|
-
expect(
|
|
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
|
|
123
|
-
|
|
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([
|
|
136
|
+
const turn = turnWithItems([cmd, agentMsg], { status: 'completed', agent_response: 'Done' });
|
|
127
137
|
render(<TurnContent turn={turn} />);
|
|
128
|
-
// Collapsed —
|
|
129
|
-
expect(screen.queryByText('
|
|
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
|
|
134
|
-
|
|
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([
|
|
147
|
+
const turn = turnWithItems([cmd, agentMsg], { status: 'completed', agent_response: 'Done' });
|
|
138
148
|
render(<TurnContent turn={turn} />);
|
|
139
|
-
// Initially collapsed
|
|
140
|
-
expect(
|
|
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
|
-
|
|
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', () => {
|