repoburg 1.3.185 → 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 (49) hide show
  1. package/agent-pane/backend/package-lock.json +2359 -0
  2. package/agent-pane/backend/package.json +29 -0
  3. package/agent-pane/backend/src/codex/codex-provider.ts +363 -0
  4. package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
  5. package/agent-pane/backend/src/db/index.ts +91 -0
  6. package/agent-pane/backend/src/db/schema.ts +76 -0
  7. package/agent-pane/backend/src/index.ts +57 -0
  8. package/agent-pane/backend/src/routes/sessions.ts +134 -0
  9. package/agent-pane/backend/src/routes/turns.ts +60 -0
  10. package/agent-pane/backend/src/routes/workspaces.ts +110 -0
  11. package/agent-pane/backend/src/ws/index.ts +43 -0
  12. package/agent-pane/backend/tsconfig.json +23 -0
  13. package/agent-pane/frontend/index.html +18 -0
  14. package/agent-pane/frontend/package-lock.json +1948 -0
  15. package/agent-pane/frontend/package.json +28 -0
  16. package/agent-pane/frontend/src/App.tsx +44 -0
  17. package/agent-pane/frontend/src/AppInitializer.tsx +46 -0
  18. package/agent-pane/frontend/src/api/client.ts +41 -0
  19. package/agent-pane/frontend/src/api/sessions.ts +47 -0
  20. package/agent-pane/frontend/src/api/websocket.ts +149 -0
  21. package/agent-pane/frontend/src/api/workspaces.ts +24 -0
  22. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
  23. package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
  24. package/agent-pane/frontend/src/components/CommandPalette.tsx +207 -0
  25. package/agent-pane/frontend/src/components/DiffViewer.tsx +27 -0
  26. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +25 -0
  27. package/agent-pane/frontend/src/components/NewSessionDialog.tsx +84 -0
  28. package/agent-pane/frontend/src/components/RenderArea.tsx +170 -0
  29. package/agent-pane/frontend/src/components/RightPanel.tsx +40 -0
  30. package/agent-pane/frontend/src/components/RuntimeIcon.tsx +16 -0
  31. package/agent-pane/frontend/src/components/SessionTabs.tsx +44 -0
  32. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -0
  33. package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
  34. package/agent-pane/frontend/src/components/Toast.tsx +80 -0
  35. package/agent-pane/frontend/src/components/TurnContent.tsx +294 -0
  36. package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +350 -0
  37. package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +81 -0
  38. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
  39. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +82 -0
  40. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +45 -0
  41. package/agent-pane/frontend/src/main.tsx +10 -0
  42. package/agent-pane/frontend/src/store/store.ts +635 -0
  43. package/agent-pane/frontend/src/styles/app.css +365 -0
  44. package/agent-pane/frontend/src/styles/tokens.css +62 -0
  45. package/agent-pane/frontend/src/types/index.ts +153 -0
  46. package/agent-pane/frontend/tsconfig.json +22 -0
  47. package/agent-pane/frontend/vite.config.ts +16 -0
  48. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  49. package/package.json +2 -1
@@ -0,0 +1,635 @@
1
+ import { create } from 'zustand';
2
+ import type { Session, SessionDetail, Turn, Item, LogEntry, RightTab, ChangesScope, TokenUsage, Workspace } from '../types/index.ts';
3
+ import {
4
+ fetchSessions,
5
+ fetchSessionDetail,
6
+ createSession as apiCreateSession,
7
+ deleteSession as apiDeleteSession,
8
+ submitPrompt as apiSubmitPrompt,
9
+ cancelTurn as apiCancelTurn,
10
+ renameSession as apiRenameSession,
11
+ markFinished as apiMarkFinished,
12
+ reopenSession as apiReopenSession,
13
+ clearUnreviewed as apiClearUnreviewed,
14
+ } from '../api/sessions.ts';
15
+ import {
16
+ fetchWorkspaces as apiFetchWorkspaces,
17
+ createWorkspace as apiCreateWorkspace,
18
+ hideWorkspace as apiHideWorkspace,
19
+ pickDirectory as apiPickDirectory,
20
+ } from '../api/workspaces.ts';
21
+
22
+ export interface CreateSessionBody {
23
+ title?: string;
24
+ runtime_type?: string;
25
+ model?: string;
26
+ cwd?: string;
27
+ }
28
+
29
+ export interface Toast {
30
+ id: string;
31
+ type: 'success' | 'error' | 'info' | 'warn';
32
+ message: string;
33
+ undoAction?: () => void;
34
+ undoLabel?: string;
35
+ }
36
+
37
+ interface StoreState {
38
+ // Data
39
+ sessions: Session[];
40
+ workspaces: Workspace[];
41
+ sessionDetails: Record<string, SessionDetail>;
42
+ activeSessionId: string | null;
43
+ openTabs: string[];
44
+ activeTurnIndex: number;
45
+ activeRightTab: RightTab;
46
+ changesScope: ChangesScope;
47
+ eventLog: LogEntry[];
48
+ loading: boolean;
49
+ error: string | null;
50
+ showNewSessionDialog: boolean;
51
+ wsConnected: boolean;
52
+ toasts: Toast[];
53
+ showCommandPalette: boolean;
54
+ submittingPrompt: string | null;
55
+ selectedChangePath: string | null;
56
+
57
+ // Actions — REST
58
+ init: () => Promise<void>;
59
+ selectSession: (id: string) => Promise<void>;
60
+ closeTab: (id: string) => void;
61
+ setActiveTurnIndex: (idx: number) => void;
62
+ switchRightTab: (tab: RightTab) => void;
63
+ setChangesScope: (scope: ChangesScope) => void;
64
+ createSession: (body: CreateSessionBody) => Promise<void>;
65
+ deleteSession: (id: string) => Promise<void>;
66
+ renameSession: (id: string, title: string) => Promise<void>;
67
+ markFinished: (id: string) => Promise<void>;
68
+ reopenSession: (id: string) => Promise<void>;
69
+ submitPrompt: (prompt: string) => Promise<void>;
70
+ cancelTurn: () => Promise<void>;
71
+ refreshActiveSession: () => Promise<void>;
72
+ setShowNewSessionDialog: (show: boolean) => void;
73
+ setWsConnected: (connected: boolean) => void;
74
+ addLogEntry: (level: LogEntry['level'], message: string) => void;
75
+ addToast: (type: Toast['type'], message: string, undoAction?: () => void, undoLabel?: string) => void;
76
+ removeToast: (id: string) => void;
77
+ setShowCommandPalette: (show: boolean) => void;
78
+ setSelectedChangePath: (path: string | null) => void;
79
+ addWorkspace: (path: string) => Promise<void>;
80
+ removeWorkspace: (path: string) => Promise<void>;
81
+ createSessionInWorkspace: (cwd: string) => Promise<void>;
82
+ pickAndAddWorkspace: () => Promise<void>;
83
+
84
+ // Actions — WebSocket event handlers
85
+ onTurnStarted: (sessionId: string, turnId: string) => void;
86
+ onItemStarted: (sessionId: string, turnId: string, item: Item) => void;
87
+ onItemUpdated: (sessionId: string, turnId: string, item: Item) => void;
88
+ onItemCompleted: (sessionId: string, turnId: string, item: Item) => void;
89
+ onTurnCompleted: (sessionId: string, turnId: string, usage: TokenUsage, hasUnreviewed?: boolean) => void;
90
+ onTurnFailed: (sessionId: string, turnId: string, error: string) => void;
91
+ onSessionTitle: (sessionId: string, title: string) => void;
92
+ }
93
+
94
+ export const useStore = create<StoreState>((set, get) => ({
95
+ sessions: [],
96
+ workspaces: [],
97
+ sessionDetails: {},
98
+ activeSessionId: null,
99
+ openTabs: [],
100
+ activeTurnIndex: 0,
101
+ activeRightTab: 'turns',
102
+ changesScope: 'session',
103
+ eventLog: [],
104
+ loading: false,
105
+ error: null,
106
+ showNewSessionDialog: false,
107
+ wsConnected: false,
108
+ toasts: [],
109
+ showCommandPalette: false,
110
+ submittingPrompt: null,
111
+ selectedChangePath: null,
112
+
113
+ // === REST Actions ===
114
+
115
+ init: async () => {
116
+ set({ loading: true, error: null });
117
+ try {
118
+ const [sessions, workspaces] = await Promise.all([fetchSessions(), apiFetchWorkspaces()]);
119
+ set({ sessions, workspaces, loading: false });
120
+ if (sessions.length > 0 && !get().activeSessionId) {
121
+ await get().selectSession(sessions[0].id);
122
+ }
123
+ } catch (err) {
124
+ set({ loading: false, error: (err as Error).message });
125
+ }
126
+ },
127
+
128
+ selectSession: async (id) => {
129
+ // Check cache first
130
+ if (get().sessionDetails[id]) {
131
+ const detail = get().sessionDetails[id];
132
+ set({
133
+ activeSessionId: id,
134
+ activeTurnIndex: detail.turns.length > 0 ? detail.turns.length - 1 : 0,
135
+ activeRightTab: 'turns',
136
+ openTabs: get().openTabs.includes(id) ? get().openTabs : [...get().openTabs, id],
137
+ });
138
+ // Clear review pending indicator if set
139
+ if (detail.has_unreviewed === 1) {
140
+ apiClearUnreviewed(id).catch(() => {});
141
+ set((state) => ({
142
+ sessions: state.sessions.map((s) => s.id === id ? { ...s, has_unreviewed: 0 } : s),
143
+ sessionDetails: { ...state.sessionDetails, [id]: { ...detail, has_unreviewed: 0 } },
144
+ }));
145
+ }
146
+ return;
147
+ }
148
+
149
+ set({ loading: true, error: null });
150
+ try {
151
+ const detail = await fetchSessionDetail(id);
152
+ set((state) => ({
153
+ sessionDetails: { ...state.sessionDetails, [id]: detail },
154
+ activeSessionId: id,
155
+ activeTurnIndex: detail.turns.length > 0 ? detail.turns.length - 1 : 0,
156
+ activeRightTab: 'turns',
157
+ }));
158
+ // Clear review pending indicator if set
159
+ if (detail.has_unreviewed === 1) {
160
+ apiClearUnreviewed(id).catch(() => {});
161
+ set((state) => ({
162
+ sessions: state.sessions.map((s) => s.id === id ? { ...s, has_unreviewed: 0 } : s),
163
+ sessionDetails: { ...state.sessionDetails, [id]: { ...detail, has_unreviewed: 0 } },
164
+ }));
165
+ }
166
+ } catch (err) {
167
+ set({ loading: false, error: (err as Error).message });
168
+ }
169
+ },
170
+
171
+ closeTab: (id) => {
172
+ set((state) => {
173
+ const newTabs = state.openTabs.filter((t) => t !== id);
174
+ if (state.activeSessionId === id && newTabs.length > 0) {
175
+ const newActive = newTabs[newTabs.length - 1];
176
+ const detail = state.sessionDetails[newActive];
177
+ return {
178
+ openTabs: newTabs,
179
+ activeSessionId: newActive,
180
+ activeTurnIndex: detail && detail.turns.length > 0 ? detail.turns.length - 1 : 0,
181
+ };
182
+ }
183
+ return {
184
+ openTabs: newTabs,
185
+ activeSessionId: state.activeSessionId === id ? null : state.activeSessionId,
186
+ };
187
+ });
188
+ },
189
+
190
+ setActiveTurnIndex: (idx) => set({ activeTurnIndex: idx }),
191
+ switchRightTab: (tab) => set({ activeRightTab: tab }),
192
+ setChangesScope: (scope) => set({ changesScope: scope }),
193
+ setShowNewSessionDialog: (show) => set({ showNewSessionDialog: show }),
194
+ setShowCommandPalette: (show) => set({ showCommandPalette: show }),
195
+ setSelectedChangePath: (path) => set({ selectedChangePath: path }),
196
+
197
+ addWorkspace: async (path) => {
198
+ set({ error: null });
199
+ try {
200
+ const workspace = await apiCreateWorkspace(path.trim());
201
+ set((state) => ({ workspaces: [...state.workspaces, workspace] }));
202
+ get().addToast('success', `Workspace added — ${workspace.path}`);
203
+ } catch (err) {
204
+ get().addToast('error', `Failed to add workspace — ${(err as Error).message}`);
205
+ }
206
+ },
207
+
208
+ removeWorkspace: async (path) => {
209
+ set({ error: null });
210
+ try {
211
+ await apiHideWorkspace(path);
212
+ // Re-fetch all workspaces (includes both active + deleted)
213
+ const workspaces = await apiFetchWorkspaces();
214
+ set({ workspaces });
215
+ get().addToast('info', 'Workspace hidden');
216
+ } catch (err) {
217
+ get().addToast('error', `Failed to hide workspace — ${(err as Error).message}`);
218
+ }
219
+ },
220
+
221
+ createSessionInWorkspace: async (cwd) => {
222
+ set({ error: null });
223
+ try {
224
+ const session = await apiCreateSession({ cwd, runtime_type: 'codex' });
225
+ set((state) => ({
226
+ sessions: [session, ...state.sessions],
227
+ }));
228
+ await get().selectSession(session.id);
229
+ get().addLogEntry('INFO', `Session created in ${cwd} — ${session.id.slice(0, 8)}`);
230
+ get().addToast('success', `Session created in ${cwd.split('/').pop() || cwd}`);
231
+ } catch (err) {
232
+ set({ error: (err as Error).message });
233
+ get().addToast('error', `Failed to create session — ${(err as Error).message}`);
234
+ }
235
+ },
236
+
237
+ pickAndAddWorkspace: async () => {
238
+ try {
239
+ const result = await apiPickDirectory();
240
+ if (result.cancelled || !result.path) return;
241
+ await get().addWorkspace(result.path);
242
+ } catch (err) {
243
+ get().addToast('error', `Failed to pick directory — ${(err as Error).message}`);
244
+ }
245
+ },
246
+ setWsConnected: (connected) => set({ wsConnected: connected }),
247
+
248
+ addToast: (type, message, undoAction, undoLabel) => {
249
+ const id = Math.random().toString(36).slice(2, 9);
250
+ set((state) => ({ toasts: [...state.toasts, { id, type, message, undoAction, undoLabel }] }));
251
+ },
252
+
253
+ removeToast: (id) => {
254
+ set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
255
+ },
256
+
257
+ addLogEntry: (level, message) => {
258
+ const time = new Date().toLocaleTimeString('en-GB', { hour12: false });
259
+ set((state) => ({
260
+ eventLog: [...state.eventLog, { time, level, message }],
261
+ }));
262
+ },
263
+
264
+ createSession: async (body) => {
265
+ set({ error: null });
266
+ try {
267
+ const session = await apiCreateSession(body);
268
+ set((state) => ({
269
+ sessions: [session, ...state.sessions],
270
+ showNewSessionDialog: false,
271
+ }));
272
+ await get().selectSession(session.id);
273
+ get().addLogEntry('INFO', `Session created — ${session.id.slice(0, 8)}`);
274
+ get().addToast('success', `Session created — ${session.title || session.id.slice(0, 8)}`);
275
+ } catch (err) {
276
+ set({ error: (err as Error).message });
277
+ get().addToast('error', `Failed to create session — ${(err as Error).message}`);
278
+ }
279
+ },
280
+
281
+ deleteSession: async (id) => {
282
+ set({ error: null });
283
+ const sessionTitle = get().sessions.find((s) => s.id === id)?.title;
284
+ try {
285
+ await apiDeleteSession(id);
286
+ set((state) => {
287
+ const newDetails = { ...state.sessionDetails };
288
+ delete newDetails[id];
289
+ const newTabs = state.openTabs.filter((t) => t !== id);
290
+ let newActive = state.activeSessionId;
291
+ let newTurnIndex = state.activeTurnIndex;
292
+ if (state.activeSessionId === id) {
293
+ newActive = newTabs.length > 0 ? newTabs[newTabs.length - 1] : null;
294
+ const detail = newActive ? newDetails[newActive] : null;
295
+ newTurnIndex = detail && detail.turns.length > 0 ? detail.turns.length - 1 : 0;
296
+ }
297
+ return {
298
+ sessions: state.sessions.filter((s) => s.id !== id),
299
+ sessionDetails: newDetails,
300
+ openTabs: newTabs,
301
+ activeSessionId: newActive,
302
+ activeTurnIndex: newTurnIndex,
303
+ };
304
+ });
305
+ get().addLogEntry('INFO', `Session deleted — ${id.slice(0, 8)}`);
306
+ get().addToast('info', `Session "${sessionTitle || id.slice(0, 8)}" deleted`);
307
+ } catch (err) {
308
+ set({ error: (err as Error).message });
309
+ get().addToast('error', `Failed to delete session — ${(err as Error).message}`);
310
+ }
311
+ },
312
+
313
+ renameSession: async (id, title) => {
314
+ set({ error: null });
315
+ try {
316
+ const updated = await apiRenameSession(id, title.trim());
317
+ set((state) => ({
318
+ sessions: state.sessions.map((s) => (s.id === id ? updated : s)),
319
+ sessionDetails: state.sessionDetails[id]
320
+ ? { ...state.sessionDetails, [id]: { ...state.sessionDetails[id], title: updated.title } }
321
+ : state.sessionDetails,
322
+ }));
323
+ } catch (err) {
324
+ set({ error: (err as Error).message });
325
+ get().addToast('error', `Failed to rename session — ${(err as Error).message}`);
326
+ }
327
+ },
328
+
329
+ markFinished: async (id) => {
330
+ set({ error: null });
331
+ try {
332
+ const updated = await apiMarkFinished(id);
333
+ set((state) => ({
334
+ sessions: state.sessions.map((s) => (s.id === id ? updated : s)),
335
+ }));
336
+ get().addToast('info', 'Session marked as finished');
337
+ } catch (err) {
338
+ set({ error: (err as Error).message });
339
+ get().addToast('error', `Failed to mark as finished — ${(err as Error).message}`);
340
+ }
341
+ },
342
+
343
+ reopenSession: async (id) => {
344
+ set({ error: null });
345
+ try {
346
+ const updated = await apiReopenSession(id);
347
+ set((state) => ({
348
+ sessions: state.sessions.map((s) => (s.id === id ? updated : s)),
349
+ }));
350
+ get().addToast('info', 'Session reopened');
351
+ } catch (err) {
352
+ set({ error: (err as Error).message });
353
+ get().addToast('error', `Failed to reopen session — ${(err as Error).message}`);
354
+ }
355
+ },
356
+
357
+ submitPrompt: async (prompt) => {
358
+ const sessionId = get().activeSessionId;
359
+ if (!sessionId) return;
360
+
361
+ set({ error: null, submittingPrompt: prompt });
362
+
363
+ // Mark session as running immediately (visual feedback)
364
+ set((state) => {
365
+ const detail = state.sessionDetails[sessionId];
366
+ if (!detail) return {};
367
+ return {
368
+ sessionDetails: {
369
+ ...state.sessionDetails,
370
+ [sessionId]: { ...detail, is_running: 1, status: 'running' },
371
+ },
372
+ sessions: state.sessions.map((s) =>
373
+ s.id === sessionId ? { ...s, is_running: 1, status: 'running' } : s,
374
+ ),
375
+ };
376
+ });
377
+
378
+ try {
379
+ const turn = await apiSubmitPrompt(sessionId, prompt);
380
+ get().addLogEntry('INFO', `Turn submitted — "${prompt.slice(0, 40)}"`);
381
+
382
+ // Add the real turn to session detail (now with real ID for WS events)
383
+ set((state) => {
384
+ const detail = state.sessionDetails[sessionId];
385
+ if (!detail) return { submittingPrompt: null };
386
+ const newTurn: Turn = { ...turn, items: [] };
387
+ return {
388
+ submittingPrompt: null,
389
+ sessionDetails: {
390
+ ...state.sessionDetails,
391
+ [sessionId]: {
392
+ ...detail,
393
+ turns: [...detail.turns, newTurn],
394
+ },
395
+ },
396
+ activeTurnIndex: detail.turns.length, // index of new turn
397
+ };
398
+ });
399
+ } catch (err) {
400
+ set({ submittingPrompt: null, error: (err as Error).message });
401
+ get().addLogEntry('ERROR', `Turn failed — ${(err as Error).message}`);
402
+ get().addToast('error', `Failed to send prompt — ${(err as Error).message}`);
403
+
404
+ // Revert running state
405
+ set((state) => ({
406
+ sessionDetails: state.sessionDetails[sessionId]
407
+ ? { ...state.sessionDetails, [sessionId]: { ...state.sessionDetails[sessionId], is_running: 0, status: 'active' } }
408
+ : state.sessionDetails,
409
+ sessions: state.sessions.map((s) =>
410
+ s.id === sessionId ? { ...s, is_running: 0, status: 'active' } : s,
411
+ ),
412
+ }));
413
+ }
414
+ },
415
+
416
+ cancelTurn: async () => {
417
+ const sessionId = get().activeSessionId;
418
+ if (!sessionId) return;
419
+
420
+ try {
421
+ await apiCancelTurn(sessionId);
422
+ get().addLogEntry('INFO', 'Turn cancelled');
423
+ } catch (err) {
424
+ set({ error: (err as Error).message });
425
+ }
426
+ },
427
+
428
+ refreshActiveSession: async () => {
429
+ const sessionId = get().activeSessionId;
430
+ if (!sessionId) return;
431
+
432
+ try {
433
+ const detail = await fetchSessionDetail(sessionId);
434
+ set((state) => ({
435
+ sessionDetails: { ...state.sessionDetails, [sessionId]: detail },
436
+ activeTurnIndex: detail.turns.length > 0 ? Math.min(state.activeTurnIndex, detail.turns.length - 1) : 0,
437
+ sessions: state.sessions.map((s) =>
438
+ s.id === sessionId ? { ...s, is_running: detail.is_running, status: detail.status } : s,
439
+ ),
440
+ }));
441
+ } catch (err) {
442
+ set({ error: (err as Error).message });
443
+ }
444
+ },
445
+
446
+ // === WebSocket Event Handlers ===
447
+
448
+ onTurnStarted: (sessionId, turnId) => {
449
+ get().addLogEntry('DEBUG', `turn:started — ${turnId.slice(0, 8)}`);
450
+
451
+ set((state) => {
452
+ const detail = state.sessionDetails[sessionId];
453
+ if (!detail) return {};
454
+
455
+ return {
456
+ sessionDetails: {
457
+ ...state.sessionDetails,
458
+ [sessionId]: {
459
+ ...detail,
460
+ is_running: 1,
461
+ status: 'running',
462
+ turns: detail.turns.map((t) =>
463
+ t.id === turnId ? { ...t, status: 'running' } : t,
464
+ ),
465
+ },
466
+ },
467
+ sessions: state.sessions.map((s) =>
468
+ s.id === sessionId ? { ...s, is_running: 1, status: 'running' } : s,
469
+ ),
470
+ // If this is the active session, switch to the running turn
471
+ activeTurnIndex: state.activeSessionId === sessionId
472
+ ? detail.turns.findIndex((t) => t.id === turnId)
473
+ : state.activeTurnIndex,
474
+ };
475
+ });
476
+ },
477
+
478
+ onItemStarted: (sessionId, turnId, item) => {
479
+ get().addLogEntry('DEBUG', `item:started — ${item.type}`);
480
+
481
+ set((state) => {
482
+ const detail = state.sessionDetails[sessionId];
483
+ if (!detail) return {};
484
+
485
+ return {
486
+ sessionDetails: {
487
+ ...state.sessionDetails,
488
+ [sessionId]: upsertItem(detail, turnId, item),
489
+ },
490
+ };
491
+ });
492
+ },
493
+
494
+ onItemUpdated: (sessionId, turnId, item) => {
495
+ // No log for item:updated — too noisy (fires frequently during streaming)
496
+
497
+ set((state) => {
498
+ const detail = state.sessionDetails[sessionId];
499
+ if (!detail) return {};
500
+
501
+ return {
502
+ sessionDetails: {
503
+ ...state.sessionDetails,
504
+ [sessionId]: upsertItem(detail, turnId, item),
505
+ },
506
+ };
507
+ });
508
+ },
509
+
510
+ onItemCompleted: (sessionId, turnId, item) => {
511
+ get().addLogEntry('DEBUG', `item:completed — ${item.type}`);
512
+
513
+ set((state) => {
514
+ const detail = state.sessionDetails[sessionId];
515
+ if (!detail) return {};
516
+
517
+ const updatedDetail = upsertItem(detail, turnId, item);
518
+
519
+ // Update turn-level fields from item
520
+ let turns = updatedDetail.turns;
521
+ if (item.type === 'agent_message' && item.data?.text) {
522
+ turns = turns.map((t) =>
523
+ t.id === turnId ? { ...t, agent_response: item.data!.text as string } : t,
524
+ );
525
+ }
526
+ if (item.type === 'reasoning' && item.data?.text) {
527
+ turns = turns.map((t) => {
528
+ if (t.id !== turnId) return t;
529
+ const thoughts = t.thoughts ? t.thoughts + '\n' + (item.data!.text as string) : (item.data!.text as string);
530
+ return { ...t, thoughts };
531
+ });
532
+ }
533
+
534
+ return {
535
+ sessionDetails: {
536
+ ...state.sessionDetails,
537
+ [sessionId]: { ...updatedDetail, turns },
538
+ },
539
+ };
540
+ });
541
+ },
542
+
543
+ onTurnCompleted: (sessionId, turnId, usage, hasUnreviewed) => {
544
+ get().addLogEntry('INFO', `turn:completed — ${usage.input_tokens} in / ${usage.output_tokens} out`);
545
+
546
+ set((state) => {
547
+ const detail = state.sessionDetails[sessionId];
548
+ if (!detail) return {};
549
+
550
+ return {
551
+ sessionDetails: {
552
+ ...state.sessionDetails,
553
+ [sessionId]: {
554
+ ...detail,
555
+ is_running: 0,
556
+ status: 'active',
557
+ has_unreviewed: hasUnreviewed ? 1 : detail.has_unreviewed,
558
+ turns: detail.turns.map((t) =>
559
+ t.id === turnId ? { ...t, status: 'completed', token_usage: usage } : t,
560
+ ),
561
+ },
562
+ },
563
+ sessions: state.sessions.map((s) =>
564
+ s.id === sessionId ? { ...s, is_running: 0, status: 'active', has_unreviewed: hasUnreviewed ? 1 : s.has_unreviewed } : s,
565
+ ),
566
+ };
567
+ });
568
+ },
569
+
570
+ onTurnFailed: (sessionId, turnId, error) => {
571
+ get().addLogEntry('ERROR', `turn:failed — ${error}`);
572
+ get().addToast('error', `Turn failed — ${error}`);
573
+
574
+ set((state) => {
575
+ const detail = state.sessionDetails[sessionId];
576
+ if (!detail) return {};
577
+
578
+ const isAbort = error.includes('cancelled') || error.includes('aborted');
579
+ const status = isAbort ? 'aborted' : 'failed';
580
+
581
+ return {
582
+ sessionDetails: {
583
+ ...state.sessionDetails,
584
+ [sessionId]: {
585
+ ...detail,
586
+ is_running: 0,
587
+ status: isAbort ? 'active' : 'failed',
588
+ turns: detail.turns.map((t) =>
589
+ t.id === turnId ? { ...t, status, error } : t,
590
+ ),
591
+ },
592
+ },
593
+ sessions: state.sessions.map((s) =>
594
+ s.id === sessionId ? { ...s, is_running: 0, status: isAbort ? 'active' : 'failed' } : s,
595
+ ),
596
+ };
597
+ });
598
+ },
599
+
600
+ onSessionTitle: (sessionId, title) => {
601
+ set((state) => ({
602
+ sessions: state.sessions.map((s) =>
603
+ s.id === sessionId ? { ...s, title } : s,
604
+ ),
605
+ sessionDetails: state.sessionDetails[sessionId]
606
+ ? { ...state.sessionDetails, [sessionId]: { ...state.sessionDetails[sessionId], title } }
607
+ : state.sessionDetails,
608
+ }));
609
+ },
610
+ }));
611
+
612
+ /** Insert or replace an item in the correct turn within a SessionDetail. */
613
+ function upsertItem(detail: SessionDetail, turnId: string, item: Item): SessionDetail {
614
+ return {
615
+ ...detail,
616
+ turns: detail.turns.map((turn) => {
617
+ if (turn.id !== turnId) return turn;
618
+
619
+ // Find existing item by external_item_id (SDK uses same ID across started/updated/completed)
620
+ const existingIdx = turn.items.findIndex(
621
+ (i) => i.external_item_id === item.external_item_id,
622
+ );
623
+
624
+ if (existingIdx >= 0) {
625
+ // Replace existing item
626
+ const newItems = [...turn.items];
627
+ newItems[existingIdx] = { ...item, id: newItems[existingIdx].id };
628
+ return { ...turn, items: newItems };
629
+ }
630
+
631
+ // Insert new item
632
+ return { ...turn, items: [...turn.items, item] };
633
+ }),
634
+ };
635
+ }