repoburg 1.3.186 → 1.3.188

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 (25) hide show
  1. package/agent-pane/backend/src/codex/codex-provider.ts +105 -2
  2. package/agent-pane/backend/src/db/index.ts +24 -0
  3. package/agent-pane/backend/src/db/schema.ts +11 -0
  4. package/agent-pane/backend/src/index.ts +2 -0
  5. package/agent-pane/backend/src/routes/sessions.ts +37 -0
  6. package/agent-pane/backend/src/routes/workspaces.ts +110 -0
  7. package/agent-pane/frontend/src/api/client.ts +12 -0
  8. package/agent-pane/frontend/src/api/sessions.ts +17 -1
  9. package/agent-pane/frontend/src/api/websocket.ts +11 -1
  10. package/agent-pane/frontend/src/api/workspaces.ts +24 -0
  11. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
  12. package/agent-pane/frontend/src/components/CommandPalette.tsx +11 -0
  13. package/agent-pane/frontend/src/components/RenderArea.tsx +61 -48
  14. package/agent-pane/frontend/src/components/SessionTabs.tsx +6 -3
  15. package/agent-pane/frontend/src/components/StatusBar.tsx +13 -5
  16. package/agent-pane/frontend/src/components/Toast.tsx +60 -14
  17. package/agent-pane/frontend/src/components/TurnContent.tsx +104 -12
  18. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +325 -35
  19. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +6 -2
  20. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +15 -2
  21. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +18 -57
  22. package/agent-pane/frontend/src/store/store.ts +159 -16
  23. package/agent-pane/frontend/src/styles/app.css +79 -2
  24. package/agent-pane/frontend/src/types/index.ts +46 -8
  25. package/package.json +1 -1
@@ -1,14 +1,30 @@
1
1
  import { useStore } from '../store/store.ts';
2
2
  import TurnContent from './TurnContent.tsx';
3
3
  import type { Item } from '../types/index.ts';
4
- import { memo } from 'react';
4
+ import { memo, useEffect, useRef } from 'react';
5
5
 
6
6
  function RenderAreaBase() {
7
- const { activeSessionId, sessionDetails, activeTurnIndex, activeRightTab, changesScope, submittingPrompt } = useStore();
7
+ const { activeSessionId, sessionDetails, activeTurnIndex, activeRightTab, changesScope, submittingPrompt, loading, selectedChangePath, setSelectedChangePath } = useStore();
8
8
 
9
9
  const detail = activeSessionId ? sessionDetails[activeSessionId] : null;
10
10
  const turns = detail?.turns ?? [];
11
- const turn = turns[activeTurnIndex];
11
+ const renderScrollRef = useRef<HTMLDivElement>(null);
12
+
13
+ // F4: Scroll to selected change path when it changes
14
+ useEffect(() => {
15
+ if (!selectedChangePath || activeRightTab !== 'changes') return;
16
+ const timer = setTimeout(() => {
17
+ const sanitized = selectedChangePath.replace(/[^a-zA-Z0-9]/g, '-');
18
+ const el = document.getElementById(`diff-${sanitized}`);
19
+ if (el) {
20
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
21
+ el.classList.add('diff-highlight');
22
+ setTimeout(() => el.classList.remove('diff-highlight'), 1500);
23
+ }
24
+ setSelectedChangePath(null);
25
+ }, 50);
26
+ return () => clearTimeout(timer);
27
+ }, [selectedChangePath, activeRightTab, setSelectedChangePath]);
12
28
 
13
29
  // When Changes tab is active, render file changes from items
14
30
  if (activeRightTab === 'changes') {
@@ -20,7 +36,6 @@ function RenderAreaBase() {
20
36
  );
21
37
  }
22
38
 
23
- // Collect all file_change items across turns
24
39
  const allChanges: Array<{ path: string; kind: string; turnIdx: number }> = [];
25
40
  turns.forEach((t, tIdx) => {
26
41
  t.items.forEach((item: Item) => {
@@ -33,16 +48,11 @@ function RenderAreaBase() {
33
48
 
34
49
  let entries = allChanges;
35
50
  if (changesScope === 'turngroup') {
36
- let start = activeTurnIndex;
37
- while (start > 0 && turns[start] && false) start--; // no user/agent distinction in real turns
38
51
  entries = allChanges.filter((c) => c.turnIdx === activeTurnIndex);
39
52
  }
40
53
 
41
- // Deduplicate by path (keep last)
42
54
  const seen = new Map<string, { path: string; kind: string }>();
43
- for (const c of entries) {
44
- seen.set(c.path, { path: c.path, kind: c.kind });
45
- }
55
+ for (const c of entries) seen.set(c.path, { path: c.path, kind: c.kind });
46
56
  const deduped = Array.from(seen.values());
47
57
 
48
58
  if (deduped.length === 0) {
@@ -56,7 +66,7 @@ function RenderAreaBase() {
56
66
  }
57
67
 
58
68
  return (
59
- <div className="render-scroll">
69
+ <div className="render-scroll" ref={renderScrollRef}>
60
70
  <div className="render-content">
61
71
  <div style={{ marginBottom: '20px' }}>
62
72
  <span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text)' }}>
@@ -72,10 +82,11 @@ function RenderAreaBase() {
72
82
  const statusLabel = c.kind === 'add' ? 'Added' : c.kind === 'delete' ? 'Deleted' : 'Modified';
73
83
  const statusClass = c.kind === 'add' ? 'fc-add' : c.kind === 'delete' ? 'fc-del' : 'fc-mod';
74
84
  const statusChar = c.kind === 'add' ? 'A' : c.kind === 'delete' ? 'D' : 'M';
85
+ const sanitizedId = c.path.replace(/[^a-zA-Z0-9]/g, '-');
75
86
 
76
87
  return (
77
88
  <div key={c.path}>
78
- <div className="diff-file-header">
89
+ <div className="diff-file-header" id={`diff-${sanitizedId}`}>
79
90
  <span className={`diff-file-status ${statusClass}`}>{statusChar}</span>
80
91
  <span className="diff-file-name">
81
92
  {dir && <span style={{ color: 'var(--text-muted)' }}>{dir}/</span>}
@@ -96,12 +107,35 @@ function RenderAreaBase() {
96
107
  );
97
108
  }
98
109
 
99
- // Default: render turn content
100
- if (!turn) {
101
- // Show "Sending…" if a prompt was just submitted but turn hasn't been created yet
102
- if (submittingPrompt) {
103
- return (
104
- <div className="render-scroll">
110
+ // F2: Skeleton loading when fetching session detail for the first time
111
+ if (loading && !detail) {
112
+ return (
113
+ <div className="render-scroll">
114
+ <div className="skeleton-bars">
115
+ <div className="skeleton-bar" />
116
+ <div className="skeleton-bar" />
117
+ <div className="skeleton-bar" />
118
+ <div className="skeleton-bar" />
119
+ <div className="skeleton-bar" />
120
+ </div>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ if (!detail) {
126
+ return (
127
+ <div className="render-scroll">
128
+ <div className="empty-state">No session selected</div>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ // Render ALL turns, scrollable
134
+ return (
135
+ <div className="render-scroll" ref={renderScrollRef}>
136
+ {/* Submitting prompt — shown at top while waiting for API response */}
137
+ {submittingPrompt && (
138
+ <div className="turn-block">
105
139
  <div className="turn-group">
106
140
  <div className="render-content">
107
141
  <div className="turn-header">
@@ -115,40 +149,19 @@ function RenderAreaBase() {
115
149
  </div>
116
150
  </div>
117
151
  </div>
118
- );
119
- }
152
+ )}
120
153
 
121
- return (
122
- <div className="render-scroll">
154
+ {turns.length === 0 && !submittingPrompt && (
123
155
  <div className="empty-state">
124
- {turns.length === 0 ? 'No turns yet. Send a message below to start.' : 'Select a turn to view content'}
156
+ No turns yet. Send a message below to start.
125
157
  </div>
126
- </div>
127
- );
128
- }
158
+ )}
129
159
 
130
- // Error / aborted banner
131
- let banner: React.ReactNode = null;
132
- if (turn.status === 'failed' && turn.error) {
133
- banner = (
134
- <div className="error-banner">
135
- <span className="banner-icon">!</span>
136
- <span className="banner-text">{turn.error}</span>
137
- </div>
138
- );
139
- } else if (turn.status === 'aborted') {
140
- banner = (
141
- <div className="aborted-banner">
142
- <span className="banner-icon">■</span>
143
- <span className="banner-text">Turn was cancelled by user.</span>
144
- </div>
145
- );
146
- }
147
-
148
- return (
149
- <div className="render-scroll">
150
- {banner}
151
- <TurnContent turn={turn} />
160
+ {turns.map((turn, idx) => (
161
+ <div key={turn.id} id={`turn-${idx}`} className="turn-block">
162
+ <TurnContent turn={turn} />
163
+ </div>
164
+ ))}
152
165
  </div>
153
166
  );
154
167
  }
@@ -1,6 +1,7 @@
1
1
  import { useStore } from '../store/store.ts';
2
- import { statusToDot } from '../types/index.ts';
2
+ import { statusToDot, runtimeIcon } from '../types/index.ts';
3
3
  import StatusDot from './StatusDot.tsx';
4
+ import RuntimeIcon from './RuntimeIcon.tsx';
4
5
  import { X, Plus } from 'lucide-react';
5
6
 
6
7
  export default function SessionTabs() {
@@ -11,16 +12,18 @@ export default function SessionTabs() {
11
12
  {openTabs.map((sid) => {
12
13
  const s = sessions.find((x) => x.id === sid);
13
14
  if (!s) return null;
14
- const dot = statusToDot(s.status, s.is_running === 1);
15
+ const dot = statusToDot(s.status, s.is_running === 1, s.has_unreviewed === 1, s.lifecycle);
15
16
  const isActive = activeSessionId === sid;
17
+ const ri = runtimeIcon(s.runtime_type);
16
18
  return (
17
19
  <div
18
20
  key={sid}
19
21
  className={`ide-tab ${isActive ? 'active' : ''}`}
20
22
  onClick={() => selectSession(sid)}
21
23
  >
24
+ <RuntimeIcon iconClass={ri.iconClass} iconText={ri.iconText} size={14} />
22
25
  {s.title || sid.slice(0, 8)}
23
- {s.is_running === 1 && <StatusDot status={dot} />}
26
+ {dot && <StatusDot status={dot} />}
24
27
  <span
25
28
  className="ide-tab-close"
26
29
  onClick={(e) => {
@@ -1,5 +1,5 @@
1
1
  import { useStore } from '../store/store.ts';
2
- import { statusToDot, statusToLabel } from '../types/index.ts';
2
+ import { statusToDot, statusToLabel, formatDuration } from '../types/index.ts';
3
3
  import StatusDot from './StatusDot.tsx';
4
4
  import { Zap, Command } from 'lucide-react';
5
5
 
@@ -24,9 +24,17 @@ export default function StatusBar() {
24
24
  }
25
25
 
26
26
  const isRunning = session.is_running === 1;
27
- const dot = statusToDot(session.status, isRunning);
28
- const label = statusToLabel(session.status, isRunning);
29
- const turnCount = detail?.turns.length ?? 0;
27
+ const dot = statusToDot(session.status, isRunning, session.has_unreviewed === 1, session.lifecycle);
28
+ const label = statusToLabel(session.status, isRunning, session.has_unreviewed === 1, session.lifecycle);
29
+
30
+ // Calculate duration
31
+ let duration = '—';
32
+ if (detail && detail.turns.length > 0) {
33
+ const firstTurn = detail.turns[0];
34
+ const lastTurn = detail.turns[detail.turns.length - 1];
35
+ const endISO = isRunning ? null : lastTurn.updated_at;
36
+ duration = formatDuration(firstTurn.created_at, endISO);
37
+ }
30
38
 
31
39
  return (
32
40
  <div className="ide-statusbar">
@@ -52,7 +60,7 @@ export default function StatusBar() {
52
60
  <StatusDot status={dot} /> {label}
53
61
  </div>
54
62
  <div className="sb-divider">|</div>
55
- <div className="sb-item">{turnCount} turn{turnCount !== 1 ? 's' : ''}</div>
63
+ <div className="sb-item">{duration}</div>
56
64
  <div className="sb-divider">|</div>
57
65
  <div className="sb-item"><Command size={14} /> K</div>
58
66
  </div>
@@ -1,5 +1,6 @@
1
+ import { useEffect, useRef } from 'react';
1
2
  import { useStore } from '../store/store.ts';
2
- import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
3
+ import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Undo2 } from 'lucide-react';
3
4
  import type { Toast as ToastType } from '../store/store.ts';
4
5
 
5
6
  const ICONS: Record<ToastType['type'], typeof CheckCircle> = {
@@ -9,26 +10,71 @@ const ICONS: Record<ToastType['type'], typeof CheckCircle> = {
9
10
  warn: AlertTriangle,
10
11
  };
11
12
 
13
+ const AUTO_DISMISS_MS = 4000;
14
+
15
+ function ToastItem({ toast }: { toast: ToastType }) {
16
+ const removeToast = useStore((s) => s.removeToast);
17
+ const Icon = ICONS[toast.type];
18
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
19
+
20
+ // Manage auto-dismiss with hover-to-pause
21
+ useEffect(() => {
22
+ function startTimer() {
23
+ timerRef.current = setTimeout(() => removeToast(toast.id), AUTO_DISMISS_MS);
24
+ }
25
+ startTimer();
26
+ return () => {
27
+ if (timerRef.current) clearTimeout(timerRef.current);
28
+ };
29
+ }, [toast.id, removeToast]);
30
+
31
+ function handleMouseEnter() {
32
+ if (timerRef.current) {
33
+ clearTimeout(timerRef.current);
34
+ timerRef.current = null;
35
+ }
36
+ }
37
+
38
+ function handleMouseLeave() {
39
+ timerRef.current = setTimeout(() => removeToast(toast.id), 2000);
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className={`toast toast-${toast.type}`}
45
+ onMouseEnter={handleMouseEnter}
46
+ onMouseLeave={handleMouseLeave}
47
+ >
48
+ <Icon size={16} />
49
+ <span className="toast-message">{toast.message}</span>
50
+ {toast.undoAction && (
51
+ <button
52
+ className="toast-undo"
53
+ onClick={() => {
54
+ toast.undoAction?.();
55
+ removeToast(toast.id);
56
+ }}
57
+ >
58
+ <Undo2 size={12} /> {toast.undoLabel || 'Undo'}
59
+ </button>
60
+ )}
61
+ <button className="toast-close" onClick={() => removeToast(toast.id)}>
62
+ <X size={14} />
63
+ </button>
64
+ </div>
65
+ );
66
+ }
67
+
12
68
  export default function Toast() {
13
69
  const toasts = useStore((s) => s.toasts);
14
- const removeToast = useStore((s) => s.removeToast);
15
70
 
16
71
  if (toasts.length === 0) return null;
17
72
 
18
73
  return (
19
74
  <div className="toast-container">
20
- {toasts.map((toast) => {
21
- const Icon = ICONS[toast.type];
22
- return (
23
- <div key={toast.id} className={`toast toast-${toast.type}`}>
24
- <Icon size={16} />
25
- <span className="toast-message">{toast.message}</span>
26
- <button className="toast-close" onClick={() => removeToast(toast.id)}>
27
- <X size={14} />
28
- </button>
29
- </div>
30
- );
31
- })}
75
+ {toasts.map((toast) => (
76
+ <ToastItem key={toast.id} toast={toast} />
77
+ ))}
32
78
  </div>
33
79
  );
34
80
  }
@@ -1,7 +1,9 @@
1
1
  import { marked } from 'marked';
2
2
  import DOMPurify from 'dompurify';
3
- import { memo } from 'react';
3
+ import { memo, useState, useEffect } from 'react';
4
+ import { Layers, ChevronRight } from 'lucide-react';
4
5
  import type { Turn, Item } from '../types/index.ts';
6
+ import CollapsibleToolCalls, { isToolItem } from './CollapsibleToolCalls.tsx';
5
7
 
6
8
  marked.setOptions({ breaks: true, gfm: true });
7
9
 
@@ -157,15 +159,65 @@ function ItemCard({ item }: { item: Item }) {
157
159
  }
158
160
 
159
161
  const MemoItemCard = memo(ItemCard);
162
+ export { MemoItemCard };
160
163
 
161
164
  interface Props {
162
165
  turn: Turn;
163
166
  }
164
167
 
165
168
  function TurnContentBase({ turn }: Props) {
169
+ const [intermediateExpanded, setIntermediateExpanded] = useState(false);
170
+
171
+ // Auto-expand Level 1 when turn is running/pending, auto-collapse when completed
172
+ useEffect(() => {
173
+ if (turn.status === 'running' || turn.status === 'pending') {
174
+ setIntermediateExpanded(true);
175
+ } else if (turn.status === 'completed') {
176
+ setIntermediateExpanded(false);
177
+ }
178
+ }, [turn.status]);
179
+
180
+ // Find the last agent_message item — that is the final response
181
+ let lastAgentIdx = -1;
182
+ for (let i = turn.items.length - 1; i >= 0; i--) {
183
+ if (turn.items[i].type === 'agent_message') {
184
+ lastAgentIdx = i;
185
+ break;
186
+ }
187
+ }
188
+
189
+ const hasFinalResponse = lastAgentIdx >= 0;
190
+ // Intermediate = everything before final agent_message (or ALL items if running)
191
+ const intermediateItems = hasFinalResponse
192
+ ? turn.items.slice(0, lastAgentIdx)
193
+ : turn.items;
194
+
195
+ // Group intermediate items into segments by agent_message boundaries
196
+ // Each segment: one agent_message + following tool/non-tool items until next agent_message
197
+ interface Segment {
198
+ agentMessage: Item | null;
199
+ toolItems: Item[];
200
+ otherItems: Item[];
201
+ }
202
+ const segments: Segment[] = [];
203
+ let currentSeg: Segment | null = null;
204
+ for (const item of intermediateItems) {
205
+ if (item.type === 'agent_message') {
206
+ if (currentSeg) segments.push(currentSeg);
207
+ currentSeg = { agentMessage: item, toolItems: [], otherItems: [] };
208
+ } else {
209
+ if (!currentSeg) currentSeg = { agentMessage: null, toolItems: [], otherItems: [] };
210
+ if (isToolItem(item)) currentSeg.toolItems.push(item);
211
+ else currentSeg.otherItems.push(item);
212
+ }
213
+ }
214
+ if (currentSeg) segments.push(currentSeg);
215
+
216
+ const intermediateCount = intermediateItems.length;
217
+
166
218
  return (
167
219
  <div className="turn-group">
168
- {/* User prompt */}
220
+ {/* User prompt — always visible at top */}
169
221
  <div className="render-content">
170
222
  <div className="turn-header">
171
223
  <span className="turn-header-icon" style={{ background: 'var(--amber)', color: '#fff' }}>U</span>
@@ -174,19 +226,52 @@ function TurnContentBase({ turn }: Props) {
174
226
  <p className="user-message">{turn.user_prompt}</p>
175
227
  </div>
176
228
 
177
- {/* Items in order */}
178
- {turn.items.map((item) => (
179
- <MemoItemCard key={item.id} item={item} />
180
- ))}
181
-
182
- {/* Turn error (if any) */}
183
- {turn.error && (
184
- <div className="render-content">
185
- <pre className="cmd-output" style={{ color: 'var(--red)' }}><code>{turn.error}</code></pre>
229
+ {/* Level 1: Intermediate steps — collapsible (auto-expand while running, auto-collapse on complete) */}
230
+ {intermediateCount > 0 && (
231
+ <div className="tool-calls">
232
+ <div className="tool-calls-header" onClick={() => setIntermediateExpanded((e) => !e)}>
233
+ <span className="tool-calls-icon">
234
+ <Layers size={12} />
235
+ </span>
236
+ <span className="tool-calls-summary">
237
+ {intermediateCount} intermediate step{intermediateCount > 1 ? 's' : ''}
238
+ </span>
239
+ <ChevronRight
240
+ size={14}
241
+ className="tool-calls-chevron"
242
+ style={{ transform: intermediateExpanded ? 'rotate(90deg)' : 'none' }}
243
+ />
244
+ </div>
245
+ {intermediateExpanded && (
246
+ <div className="tool-calls-body">
247
+ {segments.map((seg, i) => (
248
+ <div key={i} className="intermediate-segment">
249
+ {/* Agent message for this segment */}
250
+ {seg.agentMessage && <MemoItemCard item={seg.agentMessage} />}
251
+ {/* Non-tool items inline (reasoning, etc.) */}
252
+ {seg.otherItems.map((item) => (
253
+ <MemoItemCard key={item.id} item={item} />
254
+ ))}
255
+ {/* Level 2: Tool calls for this segment — collapsed by default */}
256
+ {seg.toolItems.length > 0 && <CollapsibleToolCalls items={seg.toolItems} />}
257
+ </div>
258
+ ))}
259
+ </div>
260
+ )}
186
261
  </div>
187
262
  )}
188
263
 
189
- {/* Pending state (no items yet) */}
264
+ {/* Final agent message — always visible */}
265
+ {hasFinalResponse && (
266
+ <MemoItemCard key={turn.items[lastAgentIdx].id} item={turn.items[lastAgentIdx]} />
267
+ )}
268
+
269
+ {/* Items after final agent message (rare — errors, etc.) */}
270
+ {hasFinalResponse && turn.items.slice(lastAgentIdx + 1).map((item) => (
271
+ <MemoItemCard key={item.id} item={item} />
272
+ ))}
273
+
274
+ {/* Pending state (no items at all) */}
190
275
  {turn.items.length === 0 && !turn.error && (turn.status === 'pending' || turn.status === 'running') && (
191
276
  <div className="render-content">
192
277
  <p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>
@@ -194,6 +279,13 @@ function TurnContentBase({ turn }: Props) {
194
279
  </p>
195
280
  </div>
196
281
  )}
282
+
283
+ {/* Turn error (if any) */}
284
+ {turn.error && (
285
+ <div className="render-content">
286
+ <pre className="cmd-output" style={{ color: 'var(--red)' }}><code>{turn.error}</code></pre>
287
+ </div>
288
+ )}
197
289
  </div>
198
290
  );
199
291
  }