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
@@ -0,0 +1,308 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { getDb } from '../db/index.ts';
4
+ import { getProvider } from '../providers/registry.ts';
5
+ import { broadcast } from '../ws/index.ts';
6
+ import type { Session, Turn, DiscussionThread } from '../db/schema.ts';
7
+
8
+ /** Raw DB row for turns — token_usage is a JSON string, not parsed; no items field. */
9
+ interface TurnRow extends Omit<Turn, 'token_usage' | 'items'> {
10
+ token_usage: string | null;
11
+ }
12
+
13
+ /** Raw DB row for items — data is a JSON string, not parsed. */
14
+ interface ItemRow extends Omit<Turn['items'][number], 'data'> {
15
+ data: string | null;
16
+ }
17
+
18
+ export async function registerThreadRoutes(fastify: FastifyInstance): Promise<void> {
19
+ // POST /api/sessions/:sessionId/threads — create thread + first AI turn
20
+ fastify.post<{
21
+ Params: { sessionId: string };
22
+ Body: {
23
+ itemId: string;
24
+ turnId: string;
25
+ startOffset: number;
26
+ endOffset: number;
27
+ selectedText: string;
28
+ message: string;
29
+ };
30
+ }>(
31
+ '/api/sessions/:sessionId/threads',
32
+ {
33
+ schema: {
34
+ body: {
35
+ type: 'object',
36
+ required: ['itemId', 'turnId', 'startOffset', 'endOffset', 'selectedText', 'message'],
37
+ properties: {
38
+ itemId: { type: 'string' },
39
+ turnId: { type: 'string' },
40
+ startOffset: { type: 'number' },
41
+ endOffset: { type: 'number' },
42
+ selectedText: { type: 'string' },
43
+ message: { type: 'string', minLength: 1 },
44
+ },
45
+ },
46
+ },
47
+ },
48
+ async (request, reply) => {
49
+ const { sessionId } = request.params;
50
+ const { itemId, turnId, startOffset, endOffset, selectedText, message } = request.body || {};
51
+
52
+ const db = getDb();
53
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as Session | undefined;
54
+ if (!session) {
55
+ return reply.code(404).send({ error: 'Session not found' });
56
+ }
57
+ if (session.is_running) {
58
+ return reply.code(409).send({ error: 'A turn is already running for this session' });
59
+ }
60
+
61
+ const threadId = uuidv4();
62
+ const now = new Date().toISOString();
63
+
64
+ // 1. Create the discussion thread row
65
+ db.prepare(
66
+ `INSERT INTO discussion_threads (id, session_id, item_id, turn_id, start_offset, end_offset, selected_text, status, created_at, updated_at)
67
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
68
+ ).run(threadId, sessionId, itemId, turnId, startOffset, endOffset, selectedText, now, now);
69
+
70
+ // 1b. Fork the Claude session at the anchor turn so the thread has its own
71
+ // isolated conversation — main session context stays unaffected.
72
+ const provider = getProvider(session.runtime_type);
73
+ if (provider.forkSession) {
74
+ try {
75
+ const anchorTurn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn | undefined;
76
+ if (anchorTurn) {
77
+ const forkedSessionId = await provider.forkSession(session, anchorTurn);
78
+ if (forkedSessionId) {
79
+ db.prepare('UPDATE discussion_threads SET external_thread_id = ? WHERE id = ?').run(forkedSessionId, threadId);
80
+ }
81
+ }
82
+ } catch (err) {
83
+ console.error(`[threads] forkSession failed for thread ${threadId}:`, err);
84
+ // Non-fatal — thread falls back to main session conversation
85
+ }
86
+ }
87
+
88
+ // 2. Format prompt with quoted text for AI context
89
+ const formattedPrompt = `[Quoting from your previous response]:\n> ${selectedText}\n\n${message}`;
90
+
91
+ // 3. Create the turn row with thread_id
92
+ const newTurnId = uuidv4();
93
+ db.prepare(
94
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, thread_id, created_at, updated_at)
95
+ VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 'pending', NULL, ?, ?, ?)`,
96
+ ).run(newTurnId, sessionId, formattedPrompt, threadId, now, now);
97
+
98
+ const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get(newTurnId) as Turn;
99
+
100
+ // 4. Start the AI turn (fire and forget — events stream via WS)
101
+ provider
102
+ .startTurn(session, turn)
103
+ .catch((err) => {
104
+ console.error(`[threads] Unhandled error for session ${sessionId}:`, err);
105
+ });
106
+
107
+ // 5. Build the thread response
108
+ const threadRow = db.prepare('SELECT * FROM discussion_threads WHERE id = ?').get(threadId) as DiscussionThread;
109
+ const thread: DiscussionThread = {
110
+ ...threadRow,
111
+ turns: [{ ...turn, items: [] }],
112
+ };
113
+
114
+ // 6. Broadcast thread creation
115
+ broadcast('thread:created', { sessionId, thread, turn });
116
+
117
+ return { thread, turn };
118
+ },
119
+ );
120
+
121
+ // POST /api/sessions/:sessionId/threads/:threadId/followup — add follow-up message to thread
122
+ fastify.post<{
123
+ Params: { sessionId: string; threadId: string };
124
+ Body: { message: string };
125
+ }>(
126
+ '/api/sessions/:sessionId/threads/:threadId/followup',
127
+ {
128
+ schema: {
129
+ body: {
130
+ type: 'object',
131
+ required: ['message'],
132
+ properties: {
133
+ message: { type: 'string', minLength: 1 },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ async (request, reply) => {
139
+ const { sessionId, threadId } = request.params;
140
+ const { message } = request.body || {};
141
+
142
+ const db = getDb();
143
+ const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as Session | undefined;
144
+ if (!session) {
145
+ return reply.code(404).send({ error: 'Session not found' });
146
+ }
147
+ if (session.is_running) {
148
+ return reply.code(409).send({ error: 'A turn is already running for this session' });
149
+ }
150
+
151
+ const thread = db
152
+ .prepare('SELECT * FROM discussion_threads WHERE id = ? AND session_id = ?')
153
+ .get(threadId, sessionId) as DiscussionThread | undefined;
154
+ if (!thread) {
155
+ return reply.code(404).send({ error: 'Thread not found' });
156
+ }
157
+ if (thread.status !== 'open') {
158
+ return reply.code(409).send({ error: 'Thread is resolved — reopen it first' });
159
+ }
160
+
161
+ // Create new turn with same thread_id
162
+ const newTurnId = uuidv4();
163
+ const now = new Date().toISOString();
164
+ db.prepare(
165
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, thread_id, created_at, updated_at)
166
+ VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 'pending', NULL, ?, ?, ?)`,
167
+ ).run(newTurnId, sessionId, message, threadId, now, now);
168
+
169
+ const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get(newTurnId) as Turn;
170
+
171
+ // Start the AI turn
172
+ getProvider(session.runtime_type)
173
+ .startTurn(session, turn)
174
+ .catch((err) => {
175
+ console.error(`[threads] followup error for session ${sessionId}:`, err);
176
+ });
177
+
178
+ broadcast('thread:followup', { sessionId, threadId, turn });
179
+
180
+ return { turn };
181
+ },
182
+ );
183
+
184
+ // PATCH /api/sessions/:sessionId/threads/:threadId — resolve or reopen
185
+ fastify.patch<{
186
+ Params: { sessionId: string; threadId: string };
187
+ Body: { status: 'resolved' | 'open' };
188
+ }>(
189
+ '/api/sessions/:sessionId/threads/:threadId',
190
+ {
191
+ schema: {
192
+ body: {
193
+ type: 'object',
194
+ required: ['status'],
195
+ properties: {
196
+ status: { type: 'string', enum: ['resolved', 'open'] },
197
+ },
198
+ },
199
+ },
200
+ },
201
+ async (request, reply) => {
202
+ const { sessionId, threadId } = request.params;
203
+ const { status } = request.body || {};
204
+
205
+ const db = getDb();
206
+ const thread = db
207
+ .prepare('SELECT * FROM discussion_threads WHERE id = ? AND session_id = ?')
208
+ .get(threadId, sessionId) as DiscussionThread | undefined;
209
+ if (!thread) {
210
+ return reply.code(404).send({ error: 'Thread not found' });
211
+ }
212
+
213
+ const now = new Date().toISOString();
214
+ const resolvedAt = status === 'resolved' ? now : null;
215
+ db.prepare(
216
+ 'UPDATE discussion_threads SET status = ?, resolved_at = ?, updated_at = ? WHERE id = ?',
217
+ ).run(status, resolvedAt, now, threadId);
218
+
219
+ broadcast('thread:resolved', { sessionId, threadId, status });
220
+
221
+ return { id: threadId, status, resolved_at: resolvedAt };
222
+ },
223
+ );
224
+
225
+ // DELETE /api/sessions/:sessionId/threads/:threadId — delete thread + its turns + items
226
+ fastify.delete<{ Params: { sessionId: string; threadId: string } }>(
227
+ '/api/sessions/:sessionId/threads/:threadId',
228
+ async (request, reply) => {
229
+ const { sessionId, threadId } = request.params;
230
+ const db = getDb();
231
+
232
+ const thread = db
233
+ .prepare('SELECT id FROM discussion_threads WHERE id = ? AND session_id = ?')
234
+ .get(threadId, sessionId);
235
+ if (!thread) {
236
+ return reply.code(404).send({ error: 'Thread not found' });
237
+ }
238
+
239
+ // Clean up the thread's branched session entry (Claude provider)
240
+ const session = db.prepare('SELECT runtime_type FROM sessions WHERE id = ?').get(sessionId) as { runtime_type: string } | undefined;
241
+ if (session) {
242
+ const provider = getProvider(session.runtime_type);
243
+ provider.clearThreadBranch?.(sessionId, threadId);
244
+ }
245
+
246
+ // Delete items belonging to turns in this thread
247
+ db.prepare(
248
+ 'DELETE FROM items WHERE turn_id IN (SELECT id FROM turns WHERE thread_id = ?)',
249
+ ).run(threadId);
250
+ // Delete turns in this thread
251
+ db.prepare('DELETE FROM turns WHERE thread_id = ?').run(threadId);
252
+ // Delete the thread row
253
+ db.prepare('DELETE FROM discussion_threads WHERE id = ?').run(threadId);
254
+
255
+ broadcast('thread:deleted', { sessionId, threadId });
256
+
257
+ return { success: true };
258
+ },
259
+ );
260
+
261
+ // GET /api/sessions/:sessionId/threads — list all threads with their turns
262
+ fastify.get<{ Params: { sessionId: string } }>('/api/sessions/:sessionId/threads', async (request, reply) => {
263
+ const { sessionId } = request.params;
264
+ const db = getDb();
265
+
266
+ const session = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
267
+ if (!session) {
268
+ return reply.code(404).send({ error: 'Session not found' });
269
+ }
270
+
271
+ const threadRows = db
272
+ .prepare('SELECT * FROM discussion_threads WHERE session_id = ? ORDER BY created_at ASC')
273
+ .all(sessionId) as DiscussionThread[];
274
+
275
+ // Attach turns + items for each thread
276
+ const result: DiscussionThread[] = [];
277
+ for (const tr of threadRows) {
278
+ const turnRows = db
279
+ .prepare('SELECT * FROM turns WHERE thread_id = ? ORDER BY created_at ASC')
280
+ .all(tr.id) as TurnRow[];
281
+
282
+ const itemRows = db
283
+ .prepare(
284
+ 'SELECT * FROM items WHERE turn_id IN (SELECT id FROM turns WHERE thread_id = ?) ORDER BY created_at ASC',
285
+ )
286
+ .all(tr.id) as ItemRow[];
287
+
288
+ const itemsByTurn = new Map<string, Turn['items']>();
289
+ for (const row of itemRows) {
290
+ const item = { ...row, data: row.data ? JSON.parse(row.data) : null };
291
+ const list = itemsByTurn.get(item.turn_id) ?? [];
292
+ list.push(item);
293
+ itemsByTurn.set(item.turn_id, list);
294
+ }
295
+
296
+ result.push({
297
+ ...tr,
298
+ turns: turnRows.map((row) => ({
299
+ ...row,
300
+ token_usage: row.token_usage ? JSON.parse(row.token_usage) : null,
301
+ items: itemsByTurn.get(row.id) ?? [],
302
+ })),
303
+ });
304
+ }
305
+
306
+ return result;
307
+ });
308
+ }
@@ -2,11 +2,14 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { getDb } from '../db/index.ts';
4
4
  import { getProvider } from '../providers/registry.ts';
5
- import type { Session, Turn } from '../db/schema.ts';
5
+ import type { Session, Turn, Attachment } from '../db/schema.ts';
6
6
 
7
7
  export async function registerTurnRoutes(fastify: FastifyInstance): Promise<void> {
8
8
  // POST /api/sessions/:id/turns — submit prompt, starts a turn
9
- fastify.post<{ Params: { id: string }; Body: { user_prompt: string } }>(
9
+ fastify.post<{
10
+ Params: { id: string };
11
+ Body: { user_prompt: string; attachments?: Attachment[] };
12
+ }>(
10
13
  '/api/sessions/:id/turns',
11
14
  {
12
15
  schema: {
@@ -15,13 +18,25 @@ export async function registerTurnRoutes(fastify: FastifyInstance): Promise<void
15
18
  required: ['user_prompt'],
16
19
  properties: {
17
20
  user_prompt: { type: 'string', minLength: 1 },
21
+ attachments: {
22
+ type: 'array',
23
+ items: {
24
+ type: 'object',
25
+ required: ['name', 'media_type', 'data'],
26
+ properties: {
27
+ name: { type: 'string' },
28
+ media_type: { type: 'string' },
29
+ data: { type: 'string' },
30
+ },
31
+ },
32
+ },
18
33
  },
19
34
  },
20
35
  },
21
36
  },
22
37
  async (request, reply) => {
23
38
  const { id } = request.params;
24
- const { user_prompt } = request.body || {};
39
+ const { user_prompt, attachments } = request.body || {};
25
40
 
26
41
  if (!user_prompt || !user_prompt.trim()) {
27
42
  return reply.code(400).send({ error: 'user_prompt is required' });
@@ -37,24 +52,32 @@ export async function registerTurnRoutes(fastify: FastifyInstance): Promise<void
37
52
  return reply.code(409).send({ error: 'A turn is already running for this session' });
38
53
  }
39
54
 
40
- // Create turn in DB
55
+ // Create turn in DB — store attachments as JSON if provided
41
56
  const turnId = uuidv4();
42
57
  const now = new Date().toISOString();
58
+ const attachmentsJson = attachments && attachments.length > 0 ? JSON.stringify(attachments) : null;
43
59
  db.prepare(
44
- `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
45
- VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 'pending', NULL, ?, ?)`,
46
- ).run(turnId, id, user_prompt, now, now);
60
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, attachments, created_at, updated_at)
61
+ VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 'pending', NULL, ?, ?, ?)`,
62
+ ).run(turnId, id, user_prompt, attachmentsJson, now, now);
47
63
 
48
64
  const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get(turnId) as Turn;
49
65
 
66
+ // Parse attachments from JSON string (DB stores as TEXT) before returning to frontend
67
+ const rawTurn = turn as Turn & { attachments: string | null };
68
+ const parsedTurn: Turn = {
69
+ ...turn,
70
+ attachments: rawTurn.attachments ? (JSON.parse(rawTurn.attachments) as Attachment[]) : null,
71
+ };
72
+
50
73
  // Fire and forget — events stream via WebSocket
51
74
  getProvider(session.runtime_type)
52
- .startTurn(session, turn)
75
+ .startTurn(session, rawTurn)
53
76
  .catch((err) => {
54
77
  console.error(`[provider] Unhandled error for session ${id}:`, err);
55
78
  });
56
79
 
57
- return turn;
80
+ return parsedTurn;
58
81
  },
59
82
  );
60
83