repoburg 1.3.196 → 1.3.197

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/agent-pane/backend/data.db-shm +0 -0
  2. package/agent-pane/backend/data.db-wal +0 -0
  3. package/agent-pane/backend/dist/index.js +13399 -10390
  4. package/agent-pane/backend/package-lock.json +162 -92
  5. package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
  6. package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
  7. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
  8. package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
  9. package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
  10. package/agent-pane/backend/src/claude/claude-types.ts +16 -4
  11. package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
  12. package/agent-pane/backend/src/db/index.ts +102 -0
  13. package/agent-pane/backend/src/db/schema.ts +4 -0
  14. package/agent-pane/backend/src/index.ts +3 -1
  15. package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
  16. package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
  17. package/agent-pane/backend/src/integration/threads.test.ts +309 -0
  18. package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
  19. package/agent-pane/backend/src/routes/providers.ts +11 -0
  20. package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
  21. package/agent-pane/backend/src/routes/sessions.ts +133 -28
  22. package/agent-pane/backend/src/routes/threads.ts +308 -0
  23. package/agent-pane/backend/src/routes/turns.ts +32 -9
  24. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
  25. package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
  26. package/agent-pane/frontend/dist/index.html +2 -2
  27. package/agent-pane/frontend/package-lock.json +51 -9
  28. package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
  29. package/agent-pane/frontend/src/api/providers.ts +6 -1
  30. package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
  31. package/agent-pane/frontend/src/api/sessions.ts +13 -5
  32. package/agent-pane/frontend/src/api/websocket.ts +65 -1
  33. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
  34. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
  35. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
  36. package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
  37. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
  38. package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
  40. package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
  41. package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
  42. package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
  43. package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
  44. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
  45. package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
  46. package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
  47. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
  48. package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
  49. package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
  50. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
  51. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
  52. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
  53. package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
  54. package/agent-pane/frontend/src/store/store.test.ts +5 -5
  55. package/agent-pane/frontend/src/store/store.ts +4 -1
  56. package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
  57. package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
  58. package/agent-pane/frontend/src/styles/app.css +150 -19
  59. package/agent-pane/frontend/src/test/fixtures.ts +4 -0
  60. package/agent-pane/frontend/src/types/index.ts +5 -2
  61. package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
  62. package/agent-pane/frontend/vite.config.ts +2 -2
  63. package/agent-pane/shared/provider-types.ts +14 -0
  64. package/agent-pane/shared/types.ts +60 -0
  65. package/package.json +1 -1
  66. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
  67. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
  68. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
  69. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
  70. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
  71. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
@@ -1,5 +1,5 @@
1
1
  import type { StateCreator } from 'zustand';
2
- import type { Session, SessionDetail, Turn } from '../types/index.ts';
2
+ import type { Session, SessionDetail, Turn, Attachment } from '../types/index.ts';
3
3
  import type { StoreState, CreateSessionBody } from './store.ts';
4
4
  import {
5
5
  fetchSessions,
@@ -13,6 +13,7 @@ import {
13
13
  reopenSession as apiReopenSession,
14
14
  clearUnreviewed as apiClearUnreviewed,
15
15
  rewindSession as apiRewindSession,
16
+ restoreSession as apiRestoreSession,
16
17
  branchSession as apiBranchSession,
17
18
  updateModelConfig as apiUpdateModelConfig,
18
19
  updateRuntimeConfig as apiUpdateRuntimeConfig,
@@ -27,6 +28,7 @@ export type SessionSlice = {
27
28
  activeTurnIndex: number;
28
29
  submittingPrompt: string | null;
29
30
  restoredPrompt: string | null;
31
+ quotedText: string | null;
30
32
 
31
33
  init: () => Promise<void>;
32
34
  selectSession: (id: string) => Promise<void>;
@@ -36,10 +38,11 @@ export type SessionSlice = {
36
38
  renameSession: (id: string, title: string) => Promise<void>;
37
39
  markFinished: (id: string) => Promise<void>;
38
40
  reopenSession: (id: string) => Promise<void>;
39
- submitPrompt: (prompt: string) => Promise<void>;
41
+ submitPrompt: (prompt: string, attachments?: Attachment[]) => Promise<void>;
40
42
  cancelTurn: () => Promise<void>;
41
43
  refreshActiveSession: () => Promise<void>;
42
44
  rewindToTurn: (sessionId: string, turnId: string) => Promise<void>;
45
+ restoreSession: (sessionId: string) => Promise<void>;
43
46
  branchFromTurn: (sessionId: string, turnId: string, title?: string) => Promise<void>;
44
47
  updateModelConfig: (model: string, reasoningEffort: string, runtimeType?: string) => Promise<void>;
45
48
  updateRuntimeConfig: (config: Record<string, unknown>) => Promise<void>;
@@ -53,6 +56,7 @@ export const createSessionSlice: StateCreator<StoreState, [], [], SessionSlice>
53
56
  activeTurnIndex: 0,
54
57
  submittingPrompt: null,
55
58
  restoredPrompt: null,
59
+ quotedText: null,
56
60
 
57
61
  init: async () => {
58
62
  try {
@@ -274,7 +278,7 @@ export const createSessionSlice: StateCreator<StoreState, [], [], SessionSlice>
274
278
  }
275
279
  },
276
280
 
277
- submitPrompt: async (prompt) => {
281
+ submitPrompt: async (prompt, attachments) => {
278
282
  const sessionId = get().activeSessionId;
279
283
  if (!sessionId) return;
280
284
 
@@ -293,7 +297,7 @@ export const createSessionSlice: StateCreator<StoreState, [], [], SessionSlice>
293
297
  });
294
298
 
295
299
  try {
296
- const turn = await apiSubmitPrompt(sessionId, prompt);
300
+ const turn = await apiSubmitPrompt(sessionId, prompt, attachments);
297
301
  get().addLogEntry('INFO', `Turn submitted — "${prompt.slice(0, 40)}"`);
298
302
 
299
303
  set((state) => {
@@ -343,12 +347,12 @@ export const createSessionSlice: StateCreator<StoreState, [], [], SessionSlice>
343
347
  rewindToTurn: async (sessionId, turnId) => {
344
348
  set({ error: null });
345
349
  try {
346
- await apiRewindSession(sessionId, turnId);
350
+ const updated = await apiRewindSession(sessionId, turnId);
347
351
  const detail = await fetchSessionDetail(sessionId);
348
352
  set((state) => ({
349
353
  sessionDetails: { ...state.sessionDetails, [sessionId]: detail },
350
354
  sessions: state.sessions.map((s) =>
351
- s.id === sessionId ? { ...s, external_thread_id: null, is_running: 0, status: 'active' } : s,
355
+ s.id === sessionId ? { ...s, external_thread_id: updated.external_thread_id, is_running: 0, status: 'active' } : s,
352
356
  ),
353
357
  activeTurnIndex: detail.turns.length > 0 ? detail.turns.length - 1 : 0,
354
358
  }));
@@ -359,6 +363,25 @@ export const createSessionSlice: StateCreator<StoreState, [], [], SessionSlice>
359
363
  }
360
364
  },
361
365
 
366
+ restoreSession: async (sessionId) => {
367
+ set({ error: null });
368
+ try {
369
+ const updated = await apiRestoreSession(sessionId);
370
+ const detail = await fetchSessionDetail(sessionId);
371
+ set((state) => ({
372
+ sessionDetails: { ...state.sessionDetails, [sessionId]: detail },
373
+ sessions: state.sessions.map((s) =>
374
+ s.id === sessionId ? { ...s, external_thread_id: updated.external_thread_id, is_running: 0, status: 'active' } : s,
375
+ ),
376
+ activeTurnIndex: detail.turns.length > 0 ? detail.turns.length - 1 : 0,
377
+ }));
378
+ get().addToast('info', 'Restored rewound session');
379
+ } catch (err) {
380
+ set({ error: (err as Error).message });
381
+ get().addToast('error', `Failed to restore — ${(err as Error).message}`);
382
+ }
383
+ },
384
+
362
385
  branchFromTurn: async (sessionId, turnId, title) => {
363
386
  set({ error: null });
364
387
  try {
@@ -74,7 +74,7 @@ describe('upsertItem (via onItemStarted)', () => {
74
74
  expect(updated.turns[0].items[0].external_item_id).toBe('ext-i1');
75
75
  });
76
76
 
77
- it('replaces existing item matching by external_item_id, keeps local id', () => {
77
+ it('replaces existing item matching by external_item_id, uses WS event id', () => {
78
78
  const existingItem = makeItem('local-i1', 't1');
79
79
  const turn = makeTurn('t1', 's1', { items: [existingItem] });
80
80
  const detail = makeDetail('s1', [turn]);
@@ -89,7 +89,7 @@ describe('upsertItem (via onItemStarted)', () => {
89
89
 
90
90
  const updated = useStore.getState().sessionDetails['s1'];
91
91
  expect(updated.turns[0].items).toHaveLength(1);
92
- expect(updated.turns[0].items[0].id).toBe('local-i1');
92
+ expect(updated.turns[0].items[0].id).toBe('different-local-id');
93
93
  expect(updated.turns[0].items[0].data).toEqual({ text: 'updated' });
94
94
  });
95
95
  });
@@ -339,7 +339,7 @@ describe('onItemUpdated', () => {
339
339
 
340
340
  const items = useStore.getState().sessionDetails['s1'].turns[0].items;
341
341
  expect(items).toHaveLength(1);
342
- expect(items[0].id).toBe('i1'); // local id preserved
342
+ expect(items[0].id).toBe('i2'); // uses WS event id (real DB id)
343
343
  expect(items[0].data).toEqual({ command: 'ls -la', output: 'done' });
344
344
  });
345
345
 
@@ -1199,8 +1199,8 @@ describe('simple setters', () => {
1199
1199
  });
1200
1200
 
1201
1201
  it('switchRightTab sets value', () => {
1202
- useStore.getState().switchRightTab('metrics');
1203
- expect(useStore.getState().activeRightTab).toBe('metrics');
1202
+ useStore.getState().switchRightTab('changes');
1203
+ expect(useStore.getState().activeRightTab).toBe('changes');
1204
1204
  });
1205
1205
 
1206
1206
  it('setChangesScope sets value', () => {
@@ -3,10 +3,12 @@ import type { SessionSlice } from './sessionSlice.ts';
3
3
  import type { WorkspaceSlice } from './workspaceSlice.ts';
4
4
  import type { WsSlice } from './wsSlice.ts';
5
5
  import type { UiSlice } from './uiSlice.ts';
6
+ import type { ThreadSlice } from './threadSlice.ts';
6
7
  import { createSessionSlice } from './sessionSlice.ts';
7
8
  import { createWorkspaceSlice } from './workspaceSlice.ts';
8
9
  import { createWsSlice } from './wsSlice.ts';
9
10
  import { createUiSlice } from './uiSlice.ts';
11
+ import { createThreadSlice } from './threadSlice.ts';
10
12
 
11
13
  export interface CreateSessionBody {
12
14
  title?: string;
@@ -24,11 +26,12 @@ export interface Toast {
24
26
  undoLabel?: string;
25
27
  }
26
28
 
27
- export type StoreState = SessionSlice & WorkspaceSlice & WsSlice & UiSlice;
29
+ export type StoreState = SessionSlice & WorkspaceSlice & WsSlice & UiSlice & ThreadSlice;
28
30
 
29
31
  export const useStore = create<StoreState>()((...a) => ({
30
32
  ...createSessionSlice(...a),
31
33
  ...createWorkspaceSlice(...a),
32
34
  ...createWsSlice(...a),
33
35
  ...createUiSlice(...a),
36
+ ...createThreadSlice(...a),
34
37
  }));
@@ -0,0 +1,136 @@
1
+ import type { StateCreator } from 'zustand';
2
+ import type { DiscussionThread, Turn } from '../types/index.ts';
3
+ import type { StoreState } from './store.ts';
4
+ import { apiPost, apiPatch, apiDelete } from '../api/client.ts';
5
+
6
+ export type ThreadSlice = {
7
+ createThread: (sessionId: string, params: {
8
+ itemId: string;
9
+ turnId: string;
10
+ startOffset: number;
11
+ endOffset: number;
12
+ selectedText: string;
13
+ message: string;
14
+ }) => Promise<void>;
15
+ followupThread: (sessionId: string, threadId: string, message: string) => Promise<void>;
16
+ resolveThread: (sessionId: string, threadId: string) => Promise<void>;
17
+ reopenThread: (sessionId: string, threadId: string) => Promise<void>;
18
+ deleteThread: (sessionId: string, threadId: string) => Promise<void>;
19
+
20
+ onThreadCreated: (sessionId: string, thread: DiscussionThread, turn: Turn) => void;
21
+ onThreadFollowup: (sessionId: string, threadId: string, turn: Turn) => void;
22
+ onThreadResolved: (sessionId: string, threadId: string, status: 'open' | 'resolved') => void;
23
+ onThreadDeleted: (sessionId: string, threadId: string) => void;
24
+ };
25
+
26
+ export const createThreadSlice: StateCreator<StoreState, [], [], ThreadSlice> = (set, get) => ({
27
+ createThread: async (sessionId, params) => {
28
+ try {
29
+ await apiPost(`/sessions/${sessionId}/threads`, params);
30
+ get().addLogEntry('INFO', `Thread created — "${params.message.slice(0, 40)}"`);
31
+ } catch (err) {
32
+ get().addToast('error', `Failed to create thread — ${(err as Error).message}`);
33
+ }
34
+ },
35
+
36
+ followupThread: async (sessionId, threadId, message) => {
37
+ try {
38
+ await apiPost(`/sessions/${sessionId}/threads/${threadId}/followup`, { message });
39
+ get().addLogEntry('INFO', `Thread follow-up — "${message.slice(0, 40)}"`);
40
+ } catch (err) {
41
+ get().addToast('error', `Failed to send follow-up — ${(err as Error).message}`);
42
+ }
43
+ },
44
+
45
+ resolveThread: async (sessionId, threadId) => {
46
+ try {
47
+ await apiPatch(`/sessions/${sessionId}/threads/${threadId}`, { status: 'resolved' });
48
+ } catch (err) {
49
+ get().addToast('error', `Failed to resolve thread — ${(err as Error).message}`);
50
+ }
51
+ },
52
+
53
+ reopenThread: async (sessionId, threadId) => {
54
+ try {
55
+ await apiPatch(`/sessions/${sessionId}/threads/${threadId}`, { status: 'open' });
56
+ } catch (err) {
57
+ get().addToast('error', `Failed to reopen thread — ${(err as Error).message}`);
58
+ }
59
+ },
60
+
61
+ deleteThread: async (sessionId, threadId) => {
62
+ try {
63
+ await apiDelete(`/sessions/${sessionId}/threads/${threadId}`);
64
+ } catch (err) {
65
+ get().addToast('error', `Failed to delete thread — ${(err as Error).message}`);
66
+ }
67
+ },
68
+
69
+ onThreadCreated: (sessionId, thread, turn) => {
70
+ set((state) => {
71
+ const detail = state.sessionDetails[sessionId];
72
+ if (!detail) return {};
73
+ return {
74
+ sessionDetails: {
75
+ ...state.sessionDetails,
76
+ [sessionId]: {
77
+ ...detail,
78
+ threads: [...(detail.threads ?? []), thread],
79
+ turns: [...detail.turns, { ...turn, items: [] }],
80
+ },
81
+ },
82
+ };
83
+ });
84
+ },
85
+
86
+ onThreadFollowup: (sessionId, _threadId, turn) => {
87
+ set((state) => {
88
+ const detail = state.sessionDetails[sessionId];
89
+ if (!detail) return {};
90
+ return {
91
+ sessionDetails: {
92
+ ...state.sessionDetails,
93
+ [sessionId]: {
94
+ ...detail,
95
+ turns: [...detail.turns, { ...turn, items: [] }],
96
+ },
97
+ },
98
+ };
99
+ });
100
+ },
101
+
102
+ onThreadResolved: (sessionId, threadId, status) => {
103
+ set((state) => {
104
+ const detail = state.sessionDetails[sessionId];
105
+ if (!detail) return {};
106
+ const threads = (detail.threads ?? []).map((t) =>
107
+ t.id === threadId
108
+ ? { ...t, status, resolved_at: status === 'resolved' ? new Date().toISOString() : null }
109
+ : t,
110
+ );
111
+ return {
112
+ sessionDetails: {
113
+ ...state.sessionDetails,
114
+ [sessionId]: { ...detail, threads },
115
+ },
116
+ };
117
+ });
118
+ },
119
+
120
+ onThreadDeleted: (sessionId, threadId) => {
121
+ set((state) => {
122
+ const detail = state.sessionDetails[sessionId];
123
+ if (!detail) return {};
124
+ return {
125
+ sessionDetails: {
126
+ ...state.sessionDetails,
127
+ [sessionId]: {
128
+ ...detail,
129
+ threads: (detail.threads ?? []).filter((t) => t.id !== threadId),
130
+ turns: detail.turns.filter((t) => t.thread_id !== threadId),
131
+ },
132
+ },
133
+ };
134
+ });
135
+ },
136
+ });
@@ -1,7 +1,13 @@
1
1
  import type { StateCreator } from 'zustand';
2
- import type { SessionDetail, Item, TokenUsage } from '../types/index.ts';
2
+ import type { SessionDetail, Item, TokenUsage, ContextUsage } from '../types/index.ts';
3
3
  import type { StoreState } from './store.ts';
4
4
 
5
+ export type BackgroundTaskInfo = {
6
+ task_id: string;
7
+ task_type: string;
8
+ description: string;
9
+ };
10
+
5
11
  export type WsSlice = {
6
12
  wsConnected: boolean;
7
13
  setWsConnected: (connected: boolean) => void;
@@ -12,9 +18,11 @@ export type WsSlice = {
12
18
  onItemDelta: (sessionId: string, turnId: string, itemId: string, delta: string) => void;
13
19
  onTurnCompleted: (sessionId: string, turnId: string, usage?: TokenUsage | null, hasUnreviewed?: boolean) => void;
14
20
  onTokenUsageUpdated: (sessionId: string, turnId: string, usage: TokenUsage) => void;
21
+ onContextUsageUpdated: (sessionId: string, ctx: ContextUsage) => void;
15
22
  onTurnFailed: (sessionId: string, turnId: string, error: string) => void;
16
23
  onTurnCancelled: (sessionId: string, turnId: string) => void;
17
24
  onSessionTitle: (sessionId: string, title: string) => void;
25
+ onBackgroundTasksChanged: (sessionId: string, tasks: BackgroundTaskInfo[]) => void;
18
26
  };
19
27
 
20
28
  export const createWsSlice: StateCreator<StoreState, [], [], WsSlice> = (set, get) => ({
@@ -78,6 +86,20 @@ export const createWsSlice: StateCreator<StoreState, [], [], WsSlice> = (set, ge
78
86
  onItemCompleted: (sessionId, turnId, item) => {
79
87
  get().addLogEntry('DEBUG', `item:completed — ${item.type}`);
80
88
 
89
+ // Toast on background_task completion — user needs to know the monitor/subagent finished
90
+ if (item.type === 'background_task' && (item.status === 'completed' || item.status === 'failed')) {
91
+ const data = item.data || {};
92
+ const taskType = (data.task_type as string) || 'unknown';
93
+ const desc = (data.description as string) || 'background task';
94
+ const isSubagent = taskType === 'local_agent';
95
+ const taskLabel = isSubagent ? 'Subagent' : taskType === 'local_bash' ? 'Monitor' : 'Task';
96
+ if (item.status === 'completed') {
97
+ get().addToast('success', `${taskLabel} completed: ${desc}`);
98
+ } else {
99
+ get().addToast('error', `${taskLabel} failed: ${desc}`);
100
+ }
101
+ }
102
+
81
103
  set((state) => {
82
104
  const detail = state.sessionDetails[sessionId];
83
105
  if (!detail) return {};
@@ -169,6 +191,19 @@ export const createWsSlice: StateCreator<StoreState, [], [], WsSlice> = (set, ge
169
191
  });
170
192
  },
171
193
 
194
+ onContextUsageUpdated: (sessionId, ctx) => {
195
+ set((state) => {
196
+ const detail = state.sessionDetails[sessionId];
197
+ if (!detail) return {};
198
+ return {
199
+ sessionDetails: {
200
+ ...state.sessionDetails,
201
+ [sessionId]: { ...detail, contextUsage: ctx },
202
+ },
203
+ };
204
+ });
205
+ },
206
+
172
207
  onTurnFailed: (sessionId, turnId, error) => {
173
208
  get().addLogEntry('ERROR', `turn:failed — ${error}`);
174
209
  get().addToast('error', `Turn failed — ${error}`);
@@ -187,7 +222,14 @@ export const createWsSlice: StateCreator<StoreState, [], [], WsSlice> = (set, ge
187
222
  ...detail,
188
223
  is_running: 0,
189
224
  status: isAbort ? 'active' : 'failed',
190
- turns: detail.turns.map((t) => (t.id === turnId ? { ...t, status, error } : t)),
225
+ turns: detail.turns.map((t) => {
226
+ if (t.id !== turnId) return t;
227
+ // Mark all in_progress items as failed — the turn failed so they'll never complete.
228
+ const items = t.items.map((item) =>
229
+ item.status === 'in_progress' ? { ...item, status: 'failed' as const } : item,
230
+ );
231
+ return { ...t, status, error, items };
232
+ }),
191
233
  },
192
234
  },
193
235
  sessions: state.sessions.map((s) =>
@@ -232,6 +274,19 @@ export const createWsSlice: StateCreator<StoreState, [], [], WsSlice> = (set, ge
232
274
  : state.sessionDetails,
233
275
  }));
234
276
  },
277
+
278
+ onBackgroundTasksChanged: (sessionId, tasks) => {
279
+ set((state) => {
280
+ const detail = state.sessionDetails[sessionId];
281
+ if (!detail) return {};
282
+ return {
283
+ sessionDetails: {
284
+ ...state.sessionDetails,
285
+ [sessionId]: { ...detail, backgroundTasks: tasks },
286
+ },
287
+ };
288
+ });
289
+ },
235
290
  });
236
291
 
237
292
  /** Insert or replace an item in the correct turn within a SessionDetail. */
@@ -244,16 +299,30 @@ function upsertItem(detail: SessionDetail, turnId: string, item: Item): SessionD
244
299
  const existingIdx = turn.items.findIndex((i) => i.external_item_id === item.external_item_id);
245
300
 
246
301
  if (existingIdx >= 0) {
302
+ // Replace with the WS event's item — use the real DB id, not the delta-* placeholder.
247
303
  const newItems = [...turn.items];
248
- newItems[existingIdx] = { ...item, id: newItems[existingIdx].id };
304
+ newItems[existingIdx] = item;
249
305
  return { ...turn, items: newItems };
250
306
  }
251
307
 
308
+ // New item not found by external_item_id — clean up any delta placeholder
309
+ // agent_message items (created by applyDelta with a fallback stream-text ID
310
+ // when streamingMessageIds wasn't set). Without this, the placeholder and the
311
+ // completed item coexist, causing duplicate rendering in thread cards.
312
+ if (item.type === 'agent_message') {
313
+ const cleanedItems = turn.items.filter(
314
+ (i) => !(i.type === 'agent_message' && i.id.startsWith('delta-')),
315
+ );
316
+ return { ...turn, items: [...cleanedItems, item] };
317
+ }
318
+
252
319
  return { ...turn, items: [...turn.items, item] };
253
320
  }),
254
321
  };
255
322
  }
256
323
 
324
+
325
+
257
326
  /** Append a text delta to an existing agent_message item, or create one if not found. */
258
327
  function applyDelta(detail: SessionDetail, turnId: string, itemId: string, delta: string): SessionDetail {
259
328
  return {
@@ -119,7 +119,8 @@ body {
119
119
  .user-message { font-size: 14px; color: var(--text); background: var(--accent-soft); padding: 12px 16px; border-radius: var(--r-md); border: 1px solid rgba(217,119,6,0.15); }
120
120
 
121
121
  /* === Command input === */
122
- .cmd-bar { position: relative; display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px; background: var(--surface-0); border-top: 1px solid var(--border); flex-shrink: 0; }
122
+ .cmd-bar { position: relative; display: flex; flex-direction: column; padding: 0; background: var(--surface-0); border-top: 1px solid var(--border); flex-shrink: 0; }
123
+ .cmd-input-row { display: flex; align-items: flex-end; gap: 8px; padding: 8px 12px; }
123
124
  .cmd-bar:focus-within { box-shadow: inset 0 0 0 1px rgba(217, 119, 6, 0.4); }
124
125
  .cmd-textarea { flex: 1; border: none; background: transparent; color: var(--text); font-size: 13px; font-family: var(--font-sans); outline: none; resize: none; overflow-y: auto; line-height: 1.5; padding: 6px 0; }
125
126
  .cmd-textarea::placeholder { color: var(--text-muted); }
@@ -143,11 +144,6 @@ body {
143
144
  .right-section { margin-bottom: 20px; }
144
145
  .right-section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 8px; }
145
146
 
146
- .metric-row { display: flex; justify-content: space-between; align-items: center; font-size: 12px; padding: 4px 0; border-bottom: 1px solid var(--border-muted); }
147
- .metric-row:last-child { border: none; }
148
- .metric-label { color: var(--text-3); }
149
- .metric-val { color: var(--text); font-weight: 510; font-variant-numeric: tabular-nums; }
150
-
151
147
  /* === Turns panel === */
152
148
  .turn-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: var(--r-sm); cursor: pointer; font-size: 12px; color: var(--text-3); transition: all var(--dur-fast) var(--ease); position: relative; }
153
149
  .turn-item:hover { background: rgba(0,0,0,0.04); color: var(--text-2); }
@@ -155,6 +151,7 @@ body {
155
151
  .turn-item.active::before { content: ''; position: absolute; left: 0; top: 4px; bottom: 4px; width: 2px; background: var(--accent); border-radius: var(--r-pill); }
156
152
  .turn-item-icon { width: 18px; height: 18px; border-radius: var(--r-sm); display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 8px; font-weight: 700; color: #fff; }
157
153
  .turn-item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
154
+ .turn-item-status { font-size: 10px; color: var(--amber); flex-shrink: 0; }
158
155
  .turn-item-meta { font-size: 10px; color: var(--text-muted); flex-shrink: 0; }
159
156
 
160
157
  /* === File changes === */
@@ -193,23 +190,14 @@ body {
193
190
  .scope-btn:hover { color: var(--text-2); }
194
191
  .scope-btn.active { background: var(--surface-0); color: var(--text); box-shadow: var(--inset-highlight); }
195
192
 
196
- /* === Log entries === */
197
- .log-entry { padding: 5px 0; font-size: 11px; line-height: 1.5; border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); }
198
- .log-entry:last-child { border: none; }
199
- .log-time { color: var(--text-muted); margin-right: 8px; }
200
- .log-level { font-weight: 700; margin-right: 6px; }
201
- .log-info { color: var(--accent); }
202
- .log-warn { color: var(--amber); }
203
- .log-error { color: var(--red); }
204
- .log-debug { color: var(--text-muted); }
205
- .log-msg { color: var(--text-2); }
206
-
207
193
  /* === Status Bar === */
208
194
  .ide-statusbar { display: flex; align-items: center; gap: 12px; padding: 0 12px; height: 24px; background: var(--surface-0); border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); flex-shrink: 0; font-variant-numeric: tabular-nums; }
209
195
  .sb-item { display: flex; align-items: center; gap: 3px; cursor: pointer; transition: color var(--dur-fast) var(--ease); }
210
196
  .sb-item:hover { color: var(--text-2); }
211
197
  .sb-spacer { flex: 1; }
212
198
  .sb-divider { color: var(--border); }
199
+ .sb-context { display: flex; align-items: center; gap: 5px; }
200
+ .sb-context-text { font-size: 10px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
213
201
 
214
202
  /* Model selector — 3-level cascading popover */
215
203
  .model-selector { position: relative; }
@@ -465,7 +453,150 @@ body {
465
453
  .slash-popup-check { color: var(--accent); font-size: 13px; }
466
454
 
467
455
  /* === Skill Badges === */
468
- .skill-badges { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 12px 0; }
456
+ .skill-badges { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 12px 0; }
469
457
  .skill-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px; font-size: 11px; font-family: var(--font-mono); color: var(--accent); background: var(--accent-soft); border-radius: var(--r-sm); }
470
458
  .skill-badge-remove { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 13px; line-height: 1; padding: 0; }
471
- .skill-badge-remove:hover { color: var(--red); }
459
+ .skill-badge-remove:hover { color: var(--red); }
460
+
461
+ /* === Attachment Preview Area (input bar) === */
462
+ .attachment-preview-area { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 12px 0; }
463
+ .attachment-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px 2px 2px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--surface-1); max-width: 180px; position: relative; transition: border-color var(--dur-fast) var(--ease); }
464
+ .attachment-chip:hover { border-color: var(--accent); }
465
+ .attachment-thumb { width: 18px; height: 18px; object-fit: cover; border-radius: 50%; flex-shrink: 0; }
466
+ .attachment-file-icon { width: 14px; height: 14px; color: var(--text-muted); flex-shrink: 0; }
467
+ .attachment-info { display: flex; flex-direction: column; gap: 0; overflow: hidden; min-width: 0; }
468
+ .attachment-name { font-size: 10px; font-family: var(--font-mono); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; }
469
+ .attachment-size { font-size: 9px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
470
+ .attachment-remove { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 1px; display: flex; align-items: center; justify-content: center; border-radius: var(--r-xs); transition: all var(--dur-fast) var(--ease); flex-shrink: 0; }
471
+ .attachment-remove:hover { color: var(--red); background: rgba(239,68,68,0.1); }
472
+
473
+ /* === Paperclip Button === */
474
+ .attachment-clip-btn { width: 28px; height: 28px; border: none; border-radius: var(--r-sm); background: transparent; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all var(--dur-fast) var(--ease); }
475
+ .attachment-clip-btn:hover { color: var(--accent); background: var(--surface-2); }
476
+ .attachment-clip-btn:disabled { opacity: 0.3; cursor: default; }
477
+
478
+ /* === Drag-over Highlight === */
479
+ .cmd-bar.drag-over { box-shadow: inset 0 0 0 2px var(--accent); background: var(--accent-soft); }
480
+
481
+ /* === Image Preview Modal === */
482
+ .image-preview-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; padding: 32px; animation: modal-fade-in 150ms ease-out; }
483
+ @keyframes modal-fade-in { from { opacity: 0; } to { opacity: 1; } }
484
+ .image-preview-content { position: relative; max-width: 90vw; max-height: 90vh; display: flex; flex-direction: column; align-items: center; gap: 8px; }
485
+ .image-preview-content img { max-width: 100%; max-height: 80vh; object-fit: contain; border-radius: var(--r-sm); border: 1px solid var(--border); }
486
+ .image-preview-info { display: flex; gap: 12px; font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
487
+ .image-preview-close { position: absolute; top: -12px; right: -12px; width: 28px; height: 28px; border: none; border-radius: 50%; background: var(--surface-2); color: var(--text); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all var(--dur-fast) var(--ease); }
488
+ .image-preview-close:hover { background: var(--red); color: #fff; }
489
+
490
+ /* === Attachment Pills (render area) === */
491
+ .attachment-pills { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
492
+ .attachment-pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px 2px 2px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--surface-1); font-size: 10px; font-family: var(--font-mono); color: var(--text-muted); transition: border-color var(--dur-fast) var(--ease); }
493
+ .attachment-pill:hover { border-color: var(--accent); }
494
+ .attachment-pill-thumb { width: 16px; height: 16px; object-fit: cover; border-radius: 50%; flex-shrink: 0; }
495
+ .attachment-pill-icon { color: var(--text-muted); flex-shrink: 0; }
496
+ .attachment-pill-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; }
497
+ /* === Tasks Panel === */
498
+ .right-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; margin-left: 4px; font-size: 10px; font-weight: 600; color: #fff; background: var(--accent); border-radius: 8px; }
499
+ .task-count-badge { font-size: 10px; font-weight: 500; }
500
+ .task-card { border: 1px solid var(--border); border-radius: var(--r-md); padding: 10px 12px; margin-bottom: 10px; transition: border-color var(--dur-fast) var(--ease); }
501
+ .task-card.task-running { border-color: var(--accent); }
502
+ .task-card.task-failed { border-color: var(--red); }
503
+ .task-card-header { display: flex; align-items: center; gap: 8px; }
504
+ .task-card-icon { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--r-sm); background: var(--surface-2); color: var(--text-3); flex-shrink: 0; }
505
+ .task-card-type { font-size: 13px; font-weight: 600; color: var(--text); }
506
+ .task-card-status { display: flex; align-items: center; gap: 3px; font-size: 11px; padding: 2px 6px; border-radius: var(--r-sm); flex-shrink: 0; }
507
+ .task-status-running { color: var(--accent); background: var(--accent-soft); }
508
+ .task-status-done { color: var(--green); background: var(--green-soft); }
509
+ .task-status-failed { color: var(--red); background: var(--red-soft); }
510
+ .task-card-chevron { color: var(--text-muted); flex-shrink: 0; transition: transform var(--dur-fast) var(--ease); }
511
+ .task-card-desc { font-size: 12px; color: var(--text-2); margin-top: 6px; font-family: var(--font-mono); }
512
+ .task-card-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
513
+ .task-card-body { margin-top: 8px; }
514
+ .task-output { max-height: 300px; }
515
+ .task-card-placeholder { font-size: 12px; color: var(--text-muted); font-style: italic; padding: 8px 0; }
516
+ .task-card-context { font-size: 10px; color: var(--text-muted); margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--border-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
517
+
518
+ /* === Discussion Threads === */
519
+ /* Highlight marks on anchored text */
520
+ mark[data-thread-id] { background: rgba(217, 119, 6, 0.18); border-radius: 2px; cursor: pointer; padding: 0 1px; transition: background var(--dur-fast) var(--ease); }
521
+ mark[data-thread-id]:hover { background: rgba(217, 119, 6, 0.3); }
522
+ mark[data-thread-id].thread-mark-resolved { background: transparent; border-bottom: 2px dashed var(--text-muted); }
523
+
524
+ /* Floating selection actions (Discuss + Quote) */
525
+ .selection-actions { position: fixed; z-index: 9999; display: flex; gap: 4px; }
526
+ .discuss-button { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-0); color: var(--accent); font-size: 11px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.12); transition: all var(--dur-fast) var(--ease); }
527
+ .discuss-button:hover { background: var(--accent-soft); border-color: var(--accent); }
528
+
529
+ /* Thread creation popover */
530
+ .thread-create-popover { position: fixed; z-index: 9999; min-width: 280px; max-width: 360px; background: var(--surface-0); border: 1px solid var(--border); border-radius: var(--r-md); box-shadow: 0 4px 16px rgba(0,0,0,0.15); padding: 10px; display: flex; flex-direction: column; gap: 8px; transform: translateX(-100%); }
531
+ .thread-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
532
+ .thread-create-quote { font-size: 11px; color: var(--text-muted); font-style: italic; flex: 1; line-height: 1.4; }
533
+ .thread-create-close { border: none; background: transparent; color: var(--text-muted); cursor: pointer; padding: 2px; border-radius: var(--r-sm); flex-shrink: 0; }
534
+ .thread-create-close:hover { background: var(--surface-1); color: var(--text); }
535
+ .thread-create-input { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 8px 10px; font-size: 13px; font-family: var(--font-sans); color: var(--text); background: var(--canvas); outline: none; resize: none; line-height: 1.5; transition: border-color var(--dur-fast) var(--ease); }
536
+ .thread-create-input:focus { border-color: var(--accent); box-shadow: var(--focus-ring); }
537
+ .thread-create-footer { display: flex; align-items: center; justify-content: space-between; }
538
+ .thread-create-hint { font-size: 10px; color: var(--text-muted); }
539
+ .thread-create-send { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: var(--r-sm); background: var(--accent); color: #fff; font-size: 11px; font-weight: 500; cursor: pointer; transition: background var(--dur-fast) var(--ease); }
540
+ .thread-create-send:disabled { opacity: 0.4; cursor: default; }
541
+ .thread-create-send:not(:disabled):hover { background: var(--accent-hover); }
542
+
543
+ /* Threads sidebar — filter tabs */
544
+ .thread-filter-tabs { display: flex; gap: 2px; margin-bottom: 12px; padding: 2px; background: var(--surface-1); border-radius: var(--r-md); }
545
+ .thread-filter-tab { flex: 1; padding: 4px 8px; border: none; background: transparent; color: var(--text-3); font-size: 10px; font-weight: 500; cursor: pointer; border-radius: var(--r-sm); font-family: var(--font-sans); transition: all var(--dur-fast) var(--ease); }
546
+ .thread-filter-tab:hover { color: var(--text-2); }
547
+ .thread-filter-tab.active { background: var(--surface-0); color: var(--text); box-shadow: var(--inset-highlight); }
548
+
549
+ /* Thread card */
550
+ .thread-card { border: 1px solid var(--border); border-radius: var(--r-md); padding: 10px 12px; margin-bottom: 10px; transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); }
551
+ .thread-card.thread-resolved { opacity: 0.7; }
552
+ .thread-card-highlighted { border-color: var(--accent); background: var(--accent-soft); }
553
+ .thread-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; cursor: pointer; }
554
+ .thread-card-quote { font-size: 11px; color: var(--text-muted); font-style: italic; line-height: 1.4; flex: 1; }
555
+ .thread-card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
556
+ .thread-status-badge { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-pill); }
557
+ .thread-status-open { background: var(--accent-soft); color: var(--accent); }
558
+ .thread-status-resolved { background: var(--green-soft); color: var(--green); }
559
+
560
+ /* Thread conversation */
561
+ .thread-card-conversation { margin-top: 8px; max-height: 300px; overflow-y: auto; }
562
+ .thread-card-turn { margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border-muted); }
563
+ .thread-card-turn:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
564
+ .thread-card-user-message { font-size: 12px; color: var(--text); background: var(--accent-soft); padding: 6px 10px; border-radius: var(--r-sm); border: 1px solid rgba(217,119,6,0.15); margin-bottom: 4px; }
565
+ .thread-card-ai-response { font-size: 12px; color: var(--text-2); padding: 4px 0; line-height: 1.5; }
566
+ .thread-card-ai-response p { margin-bottom: 6px; }
567
+ .thread-card-ai-response code { font-family: var(--font-mono); font-size: 11px; background: var(--surface-1); padding: 1px 4px; border-radius: var(--r-sm); color: var(--accent); }
568
+ .thread-card-ai-response pre { background: var(--surface-0); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 8px; overflow-x: auto; margin-bottom: 6px; }
569
+ .thread-card-ai-response pre code { background: none; padding: 0; }
570
+ .thread-card-pending { font-size: 11px; color: var(--text-muted); font-style: italic; padding: 4px 0; }
571
+ .thread-card-placeholder { font-size: 11px; color: var(--text-muted); font-style: italic; padding: 8px 0; }
572
+
573
+ /* Follow-up input */
574
+ .thread-card-followup { display: flex; gap: 4px; margin-top: 8px; }
575
+ .thread-card-followup-input { flex: 1; border: 1px solid var(--border); border-radius: var(--r-sm); padding: 6px 8px; font-size: 12px; font-family: var(--font-sans); color: var(--text); background: var(--canvas); outline: none; resize: none; line-height: 1.5; transition: border-color var(--dur-fast) var(--ease); }
576
+ .thread-card-followup-input:focus { border-color: var(--accent); }
577
+ .thread-card-followup-input:disabled { opacity: 0.5; }
578
+ .thread-card-followup-send { width: 28px; height: 28px; border: none; border-radius: var(--r-sm); background: var(--accent); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background var(--dur-fast) var(--ease); }
579
+ .thread-card-followup-send:disabled { opacity: 0.4; cursor: default; }
580
+ .thread-card-followup-send:not(:disabled):hover { background: var(--accent-hover); }
581
+
582
+ .thread-locate-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 2px; border-radius: 3px; display: flex; align-items: center; transition: all var(--dur-fast) var(--ease); flex-shrink: 0; }
583
+ .thread-locate-btn:hover { color: var(--accent); background: var(--accent-soft); }
584
+
585
+ /* Thread mark flash animation */
586
+ .thread-mark-flash { animation: mark-flash 1.5s ease-out; }
587
+ @keyframes mark-flash { 0% { background: rgba(217,119,6,0.5); } 100% { background: rgba(217,119,6,0.18); } }
588
+
589
+ /* Thread actions */
590
+ .thread-card-actions { display: flex; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-muted); }
591
+ .thread-action-btn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: transparent; color: var(--text-3); font-size: 11px; cursor: pointer; font-family: var(--font-sans); transition: all var(--dur-fast) var(--ease); }
592
+ .thread-action-btn:hover { background: var(--surface-1); color: var(--text); }
593
+ .thread-action-btn.thread-action-danger:hover { background: rgba(220,38,38,0.08); color: var(--red); border-color: rgba(220,38,38,0.2); }
594
+
595
+ /* === Agent call card (non-clickable status display) === */
596
+ .agent-call-card { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-left: 3px solid var(--accent); background: var(--surface); border-radius: 0 var(--r-sm) var(--r-sm) 0; }
597
+ .agent-call-icon { color: var(--text-2); display: flex; align-items: center; }
598
+ .agent-call-desc { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
599
+ .agent-call-status { font-size: 10px; font-family: var(--font-mono); padding: 1px 6px; border-radius: var(--r-sm); }
600
+ .agent-call-status.running { color: var(--accent); background: var(--accent-soft); }
601
+ .agent-call-status.done { color: var(--green); background: rgba(34,197,94,0.08); }
602
+ .agent-call-status.failed { color: var(--red); background: rgba(239,68,68,0.08); }