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.
- package/agent-pane/backend/package-lock.json +2359 -0
- package/agent-pane/backend/package.json +29 -0
- package/agent-pane/backend/src/codex/codex-provider.ts +363 -0
- package/agent-pane/backend/src/codex/item-mapper.ts +59 -0
- package/agent-pane/backend/src/db/index.ts +91 -0
- package/agent-pane/backend/src/db/schema.ts +76 -0
- package/agent-pane/backend/src/index.ts +57 -0
- package/agent-pane/backend/src/routes/sessions.ts +134 -0
- package/agent-pane/backend/src/routes/turns.ts +60 -0
- package/agent-pane/backend/src/routes/workspaces.ts +110 -0
- package/agent-pane/backend/src/ws/index.ts +43 -0
- package/agent-pane/backend/tsconfig.json +23 -0
- package/agent-pane/frontend/index.html +18 -0
- package/agent-pane/frontend/package-lock.json +1948 -0
- package/agent-pane/frontend/package.json +28 -0
- package/agent-pane/frontend/src/App.tsx +44 -0
- package/agent-pane/frontend/src/AppInitializer.tsx +46 -0
- package/agent-pane/frontend/src/api/client.ts +41 -0
- package/agent-pane/frontend/src/api/sessions.ts +47 -0
- package/agent-pane/frontend/src/api/websocket.ts +149 -0
- package/agent-pane/frontend/src/api/workspaces.ts +24 -0
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +50 -0
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +42 -0
- package/agent-pane/frontend/src/components/CommandPalette.tsx +207 -0
- package/agent-pane/frontend/src/components/DiffViewer.tsx +27 -0
- package/agent-pane/frontend/src/components/IdeWorkspace.tsx +25 -0
- package/agent-pane/frontend/src/components/NewSessionDialog.tsx +84 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +170 -0
- package/agent-pane/frontend/src/components/RightPanel.tsx +40 -0
- package/agent-pane/frontend/src/components/RuntimeIcon.tsx +16 -0
- package/agent-pane/frontend/src/components/SessionTabs.tsx +44 -0
- package/agent-pane/frontend/src/components/StatusBar.tsx +68 -0
- package/agent-pane/frontend/src/components/StatusDot.tsx +13 -0
- package/agent-pane/frontend/src/components/Toast.tsx +80 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +294 -0
- package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +350 -0
- package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +81 -0
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +25 -0
- package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +82 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +45 -0
- package/agent-pane/frontend/src/main.tsx +10 -0
- package/agent-pane/frontend/src/store/store.ts +635 -0
- package/agent-pane/frontend/src/styles/app.css +365 -0
- package/agent-pane/frontend/src/styles/tokens.css +62 -0
- package/agent-pane/frontend/src/types/index.ts +153 -0
- package/agent-pane/frontend/tsconfig.json +22 -0
- package/agent-pane/frontend/vite.config.ts +16 -0
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- 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
|
+
}
|