repoburg 1.3.186 → 1.3.188
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-pane/backend/src/codex/codex-provider.ts +105 -2
- package/agent-pane/backend/src/db/index.ts +24 -0
- package/agent-pane/backend/src/db/schema.ts +11 -0
- package/agent-pane/backend/src/index.ts +2 -0
- package/agent-pane/backend/src/routes/sessions.ts +37 -0
- package/agent-pane/backend/src/routes/workspaces.ts +110 -0
- package/agent-pane/frontend/src/api/client.ts +12 -0
- package/agent-pane/frontend/src/api/sessions.ts +17 -1
- package/agent-pane/frontend/src/api/websocket.ts +11 -1
- 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/CommandPalette.tsx +11 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +61 -48
- package/agent-pane/frontend/src/components/SessionTabs.tsx +6 -3
- package/agent-pane/frontend/src/components/StatusBar.tsx +13 -5
- package/agent-pane/frontend/src/components/Toast.tsx +60 -14
- package/agent-pane/frontend/src/components/TurnContent.tsx +104 -12
- package/agent-pane/frontend/src/components/WorkspaceSidebar.tsx +325 -35
- package/agent-pane/frontend/src/components/panels/ChangesPanel.tsx +6 -2
- package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +15 -2
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +18 -57
- package/agent-pane/frontend/src/store/store.ts +159 -16
- package/agent-pane/frontend/src/styles/app.css +79 -2
- package/agent-pane/frontend/src/types/index.ts +46 -8
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Codex, type Thread, type ThreadEvent, type ThreadItem, type ThreadOptions } from '@openai/codex-sdk';
|
|
2
2
|
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
3
5
|
import { getDb } from '../db/index.ts';
|
|
4
6
|
import { broadcast } from '../ws/index.ts';
|
|
5
7
|
import { mapItemData, getItemStatus } from './item-mapper.ts';
|
|
@@ -213,9 +215,12 @@ function handleEvent(event: ThreadEvent, sessionId: string, turnId: string): voi
|
|
|
213
215
|
const usage = event.usage;
|
|
214
216
|
db.prepare('UPDATE turns SET status = ?, token_usage = ?, updated_at = ? WHERE id = ?')
|
|
215
217
|
.run('completed', JSON.stringify(usage), now, turnId);
|
|
216
|
-
db.prepare('UPDATE sessions SET is_running = 0, status = ?, updated_at = ? WHERE id = ?')
|
|
218
|
+
db.prepare('UPDATE sessions SET is_running = 0, status = ?, has_unreviewed = 1, updated_at = ? WHERE id = ?')
|
|
217
219
|
.run('active', now, sessionId);
|
|
218
|
-
broadcast('turn:completed', { sessionId, turnId, usage });
|
|
220
|
+
broadcast('turn:completed', { sessionId, turnId, usage, hasUnreviewed: true });
|
|
221
|
+
|
|
222
|
+
// Auto-generate session title from first turn (if title is still null)
|
|
223
|
+
syncThreadTitle(sessionId);
|
|
219
224
|
break;
|
|
220
225
|
}
|
|
221
226
|
|
|
@@ -257,4 +262,102 @@ function makeItem(
|
|
|
257
262
|
created_at: createdAt,
|
|
258
263
|
updated_at: updatedAt,
|
|
259
264
|
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Tracks sessions currently generating titles — prevents duplicate throwaway threads. */
|
|
268
|
+
const titleGenerating = new Set<string>();
|
|
269
|
+
|
|
270
|
+
/** Generate a title for the session using a throwaway Codex thread.
|
|
271
|
+
* Only runs once per session — guarded by title === null AND titleGenerating set. */
|
|
272
|
+
function syncThreadTitle(sessionId: string): void {
|
|
273
|
+
const db = getDb();
|
|
274
|
+
const session = db.prepare('SELECT title FROM sessions WHERE id = ?')
|
|
275
|
+
.get(sessionId) as { title: string | null } | undefined;
|
|
276
|
+
if (!session || session.title !== null) return; // Already has a title
|
|
277
|
+
if (titleGenerating.has(sessionId)) return; // Already generating
|
|
278
|
+
|
|
279
|
+
// Get the first turn's user_prompt and agent_response
|
|
280
|
+
const turn = db.prepare('SELECT user_prompt, agent_response FROM turns WHERE session_id = ? ORDER BY created_at ASC LIMIT 1')
|
|
281
|
+
.get(sessionId) as { user_prompt: string; agent_response: string | null } | undefined;
|
|
282
|
+
if (!turn) return;
|
|
283
|
+
|
|
284
|
+
// Mark as generating — prevents concurrent title generation for same session
|
|
285
|
+
titleGenerating.add(sessionId);
|
|
286
|
+
|
|
287
|
+
// Fire-and-forget — don't block the turn:completed handler
|
|
288
|
+
generateTitleAsync(sessionId, turn.user_prompt, turn.agent_response).finally(() => {
|
|
289
|
+
titleGenerating.delete(sessionId);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Create a throwaway Codex thread, ask for a title, update the session, then clean up. */
|
|
294
|
+
async function generateTitleAsync(sessionId: string, userPrompt: string, agentResponse: string | null): Promise<void> {
|
|
295
|
+
let throwawayThreadId: string | null = null;
|
|
296
|
+
try {
|
|
297
|
+
const codex = new Codex();
|
|
298
|
+
const thread = codex.startThread({ skipGitRepoCheck: true });
|
|
299
|
+
throwawayThreadId = thread.id;
|
|
300
|
+
|
|
301
|
+
const truncatedPrompt = userPrompt.slice(0, 500);
|
|
302
|
+
const truncatedResponse = (agentResponse || '').slice(0, 500);
|
|
303
|
+
|
|
304
|
+
const titlePrompt = `Generate a concise 3-5 word title for this conversation. Reply with only the title, nothing else.\n\nUser asked: "${truncatedPrompt}"\n\nAgent responded: "${truncatedResponse}"`;
|
|
305
|
+
|
|
306
|
+
const result = await thread.run(titlePrompt);
|
|
307
|
+
const title = result.finalResponse.trim().replace(/^["']|["']$/g, '').slice(0, 100);
|
|
308
|
+
|
|
309
|
+
if (title) {
|
|
310
|
+
const db = getDb();
|
|
311
|
+
const now = new Date().toISOString();
|
|
312
|
+
db.prepare('UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?').run(title, now, sessionId);
|
|
313
|
+
broadcast('session:title', { sessionId, title });
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// Silently skip on error — session keeps null title
|
|
317
|
+
} finally {
|
|
318
|
+
// Clean up the throwaway thread
|
|
319
|
+
if (throwawayThreadId) {
|
|
320
|
+
cleanupThrowawayThread(throwawayThreadId);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Delete throwaway thread from Codex state DB, rollout file, and session_index.jsonl. */
|
|
326
|
+
function cleanupThrowawayThread(threadId: string): void {
|
|
327
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
328
|
+
|
|
329
|
+
// 1. Delete from state DB and get rollout path
|
|
330
|
+
try {
|
|
331
|
+
const codexDb = new Database(`${home}/.codex/state_5.sqlite`);
|
|
332
|
+
const row = codexDb.prepare('SELECT rollout_path FROM threads WHERE id = ?')
|
|
333
|
+
.get(threadId) as { rollout_path: string } | undefined;
|
|
334
|
+
|
|
335
|
+
codexDb.prepare('DELETE FROM thread_spawn_edges WHERE child_thread_id = ? OR parent_thread_id = ?').run(threadId, threadId);
|
|
336
|
+
codexDb.prepare('DELETE FROM threads WHERE id = ?').run(threadId);
|
|
337
|
+
codexDb.close();
|
|
338
|
+
|
|
339
|
+
// 2. Delete rollout file
|
|
340
|
+
if (row?.rollout_path) {
|
|
341
|
+
try { unlinkSync(row.rollout_path); } catch { /* file may not exist */ }
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
// DB not found — skip
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 3. Remove from session_index.jsonl
|
|
348
|
+
try {
|
|
349
|
+
const indexPath = `${home}/.codex/session_index.jsonl`;
|
|
350
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
351
|
+
const lines = content.trim().split('\n').filter((line) => {
|
|
352
|
+
try {
|
|
353
|
+
const entry = JSON.parse(line) as { id: string };
|
|
354
|
+
return entry.id !== threadId;
|
|
355
|
+
} catch {
|
|
356
|
+
return true; // keep unparseable lines
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
writeFileSync(indexPath, lines.join('\n') + '\n');
|
|
360
|
+
} catch {
|
|
361
|
+
// File not found — skip
|
|
362
|
+
}
|
|
260
363
|
}
|
|
@@ -61,7 +61,31 @@ function createTables(database: DB): void {
|
|
|
61
61
|
FOREIGN KEY (turn_id) REFERENCES turns(id) ON DELETE CASCADE
|
|
62
62
|
);
|
|
63
63
|
|
|
64
|
+
CREATE TABLE IF NOT EXISTS workspaces (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
path TEXT NOT NULL UNIQUE,
|
|
67
|
+
label TEXT,
|
|
68
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
created_at TEXT NOT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
64
72
|
CREATE INDEX IF NOT EXISTS idx_turns_session_id ON turns(session_id);
|
|
65
73
|
CREATE INDEX IF NOT EXISTS idx_items_turn_id ON items(turn_id);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_workspaces_path ON workspaces(path);
|
|
66
75
|
`);
|
|
76
|
+
|
|
77
|
+
// Migration: add 'deleted' column to existing workspaces table if missing
|
|
78
|
+
const wsColumns = database.prepare("PRAGMA table_info(workspaces)").all() as { name: string }[];
|
|
79
|
+
if (!wsColumns.some((c) => c.name === 'deleted')) {
|
|
80
|
+
database.exec('ALTER TABLE workspaces ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Migration: add 'lifecycle' + 'has_unreviewed' columns to existing sessions table
|
|
84
|
+
const sessColumns = database.prepare("PRAGMA table_info(sessions)").all() as { name: string }[];
|
|
85
|
+
if (!sessColumns.some((c) => c.name === 'lifecycle')) {
|
|
86
|
+
database.exec("ALTER TABLE sessions ADD COLUMN lifecycle TEXT NOT NULL DEFAULT 'working'");
|
|
87
|
+
}
|
|
88
|
+
if (!sessColumns.some((c) => c.name === 'has_unreviewed')) {
|
|
89
|
+
database.exec('ALTER TABLE sessions ADD COLUMN has_unreviewed INTEGER NOT NULL DEFAULT 0');
|
|
90
|
+
}
|
|
67
91
|
}
|
|
@@ -22,6 +22,8 @@ export interface Session {
|
|
|
22
22
|
external_thread_id: string | null; // Codex thread ID for resume
|
|
23
23
|
status: SessionStatus;
|
|
24
24
|
is_running: number; // 0 | 1
|
|
25
|
+
lifecycle: string; // 'working' | 'finished'
|
|
26
|
+
has_unreviewed: number; // 0 | 1
|
|
25
27
|
created_at: string; // ISO
|
|
26
28
|
updated_at: string; // ISO
|
|
27
29
|
}
|
|
@@ -59,6 +61,15 @@ export interface Item {
|
|
|
59
61
|
updated_at: string;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// Workspaces — directory paths for grouping sessions in sidebar
|
|
65
|
+
export interface Workspace {
|
|
66
|
+
id: string;
|
|
67
|
+
path: string; // directory path, e.g. "/Users/me/projects/my-app"
|
|
68
|
+
label: string | null; // optional custom label, null = use basename
|
|
69
|
+
deleted: number; // 0 = active, 1 = soft-deleted (hidden from sidebar)
|
|
70
|
+
created_at: string; // ISO
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
// Nested session detail for GET /api/sessions/:id
|
|
63
74
|
export interface SessionDetail extends Session {
|
|
64
75
|
turns: (Turn & { items: Item[] })[];
|
|
@@ -4,6 +4,7 @@ import { initDb } from './db/index.ts';
|
|
|
4
4
|
import { initWs, handleUpgrade, getWss } from './ws/index.ts';
|
|
5
5
|
import { registerSessionRoutes } from './routes/sessions.ts';
|
|
6
6
|
import { registerTurnRoutes } from './routes/turns.ts';
|
|
7
|
+
import { registerWorkspaceRoutes } from './routes/workspaces.ts';
|
|
7
8
|
|
|
8
9
|
const PORT = 3001;
|
|
9
10
|
|
|
@@ -20,6 +21,7 @@ async function main(): Promise<void> {
|
|
|
20
21
|
// Register routes
|
|
21
22
|
await registerSessionRoutes(fastify);
|
|
22
23
|
await registerTurnRoutes(fastify);
|
|
24
|
+
await registerWorkspaceRoutes(fastify);
|
|
23
25
|
|
|
24
26
|
// Initialize WebSocket server (noServer mode)
|
|
25
27
|
const wss = initWs();
|
|
@@ -94,4 +94,41 @@ export async function registerSessionRoutes(fastify: FastifyInstance): Promise<v
|
|
|
94
94
|
|
|
95
95
|
return { success: true };
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
// PATCH /api/sessions/:id — update session (title, lifecycle, has_unreviewed)
|
|
99
|
+
fastify.patch<{ Params: { id: string }; Body: { title?: string; lifecycle?: string; has_unreviewed?: number } }>(
|
|
100
|
+
'/api/sessions/:id',
|
|
101
|
+
async (request, reply) => {
|
|
102
|
+
const { id } = request.params;
|
|
103
|
+
const { title, lifecycle, has_unreviewed } = request.body || {};
|
|
104
|
+
const db = getDb();
|
|
105
|
+
|
|
106
|
+
const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(id);
|
|
107
|
+
if (!existing) {
|
|
108
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
const updates: string[] = ['updated_at = ?'];
|
|
113
|
+
const params: (string | number | null)[] = [now];
|
|
114
|
+
|
|
115
|
+
if (title !== undefined) {
|
|
116
|
+
updates.push('title = ?');
|
|
117
|
+
params.push(title);
|
|
118
|
+
}
|
|
119
|
+
if (lifecycle !== undefined) {
|
|
120
|
+
updates.push('lifecycle = ?');
|
|
121
|
+
params.push(lifecycle);
|
|
122
|
+
}
|
|
123
|
+
if (has_unreviewed !== undefined) {
|
|
124
|
+
updates.push('has_unreviewed = ?');
|
|
125
|
+
params.push(has_unreviewed);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`).run(...params, id);
|
|
129
|
+
|
|
130
|
+
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session;
|
|
131
|
+
return session;
|
|
132
|
+
},
|
|
133
|
+
);
|
|
97
134
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
import { getDb } from '../db/index.ts';
|
|
6
|
+
import type { Workspace } from '../db/schema.ts';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export async function registerWorkspaceRoutes(fastify: FastifyInstance): Promise<void> {
|
|
11
|
+
// GET /api/workspaces — list ALL workspaces (active + soft-deleted)
|
|
12
|
+
fastify.get('/api/workspaces', async () => {
|
|
13
|
+
const rows = getDb()
|
|
14
|
+
.prepare('SELECT * FROM workspaces ORDER BY created_at ASC')
|
|
15
|
+
.all() as Workspace[];
|
|
16
|
+
return rows;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// POST /api/workspaces — create workspace (or restore if soft-deleted)
|
|
20
|
+
fastify.post<{ Body: { path: string; label?: string } }>(
|
|
21
|
+
'/api/workspaces',
|
|
22
|
+
async (request, reply) => {
|
|
23
|
+
const { path, label = null } = request.body || {};
|
|
24
|
+
if (!path || !path.trim()) {
|
|
25
|
+
return reply.code(400).send({ error: 'Path is required' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trimmedPath = path.trim();
|
|
29
|
+
const db = getDb();
|
|
30
|
+
|
|
31
|
+
// Check if workspace with this path already exists
|
|
32
|
+
const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
|
|
33
|
+
|
|
34
|
+
if (existing) {
|
|
35
|
+
if (existing.deleted === 0) {
|
|
36
|
+
return reply.code(409).send({ error: 'Workspace with this path already exists' });
|
|
37
|
+
}
|
|
38
|
+
// Restore soft-deleted workspace
|
|
39
|
+
db.prepare('UPDATE workspaces SET deleted = 0, label = ? WHERE id = ?').run(label, existing.id);
|
|
40
|
+
const restored = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(existing.id) as Workspace;
|
|
41
|
+
return restored;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Create new workspace
|
|
45
|
+
const id = uuidv4();
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
db.prepare(
|
|
48
|
+
'INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, ?, 0, ?)',
|
|
49
|
+
).run(id, trimmedPath, label, now);
|
|
50
|
+
|
|
51
|
+
const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id) as Workspace;
|
|
52
|
+
return workspace;
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// GET /api/pick-directory — open native OS folder picker (macOS only)
|
|
57
|
+
fastify.get('/api/pick-directory', async (_request, reply) => {
|
|
58
|
+
try {
|
|
59
|
+
const { stdout } = await execFileAsync('osascript', [
|
|
60
|
+
'-e',
|
|
61
|
+
'POSIX path of (choose folder with prompt "Select a workspace directory")',
|
|
62
|
+
]);
|
|
63
|
+
const path = stdout.trim().replace(/\/$/, '');
|
|
64
|
+
return { path };
|
|
65
|
+
} catch {
|
|
66
|
+
// User cancelled the dialog or osascript not available
|
|
67
|
+
return reply.code(204).send({ cancelled: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// POST /api/workspaces/hide — soft-delete workspace by path (creates record if not exists)
|
|
72
|
+
fastify.post<{ Body: { path: string } }>(
|
|
73
|
+
'/api/workspaces/hide',
|
|
74
|
+
async (request, reply) => {
|
|
75
|
+
const { path } = request.body || {};
|
|
76
|
+
if (!path || !path.trim()) {
|
|
77
|
+
return reply.code(400).send({ error: 'Path is required' });
|
|
78
|
+
}
|
|
79
|
+
const trimmedPath = path.trim();
|
|
80
|
+
const db = getDb();
|
|
81
|
+
|
|
82
|
+
const existing = db.prepare('SELECT * FROM workspaces WHERE path = ?').get(trimmedPath) as Workspace | undefined;
|
|
83
|
+
if (existing) {
|
|
84
|
+
db.prepare('UPDATE workspaces SET deleted = 1 WHERE id = ?').run(existing.id);
|
|
85
|
+
} else {
|
|
86
|
+
const id = uuidv4();
|
|
87
|
+
const now = new Date().toISOString();
|
|
88
|
+
db.prepare(
|
|
89
|
+
'INSERT INTO workspaces (id, path, label, deleted, created_at) VALUES (?, ?, NULL, 1, ?)',
|
|
90
|
+
).run(id, trimmedPath, now);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { success: true };
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// DELETE /api/workspaces/:id — soft-delete workspace by ID (does NOT delete sessions)
|
|
98
|
+
fastify.delete<{ Params: { id: string } }>('/api/workspaces/:id', async (request, reply) => {
|
|
99
|
+
const { id } = request.params;
|
|
100
|
+
const db = getDb();
|
|
101
|
+
|
|
102
|
+
const existing = db.prepare('SELECT id FROM workspaces WHERE id = ? AND deleted = 0').get(id);
|
|
103
|
+
if (!existing) {
|
|
104
|
+
return reply.code(404).send({ error: 'Workspace not found' });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
db.prepare('UPDATE workspaces SET deleted = 1 WHERE id = ?').run(id);
|
|
108
|
+
return { success: true };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -26,4 +26,16 @@ export async function apiDelete<T>(path: string): Promise<T> {
|
|
|
26
26
|
throw new Error(`DELETE ${path} failed: ${res.status} ${res.statusText}`);
|
|
27
27
|
}
|
|
28
28
|
return res.json() as Promise<T>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function apiPatch<T>(path: string, body?: unknown): Promise<T> {
|
|
32
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
33
|
+
method: 'PATCH',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(`PATCH ${path} failed: ${res.status} ${res.statusText}`);
|
|
39
|
+
}
|
|
40
|
+
return res.json() as Promise<T>;
|
|
29
41
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { apiGet, apiPost, apiDelete } from './client.ts';
|
|
1
|
+
import { apiGet, apiPost, apiDelete, apiPatch } from './client.ts';
|
|
2
2
|
import type { Session, SessionDetail, Turn } from '../types/index.ts';
|
|
3
3
|
|
|
4
4
|
export function fetchSessions(): Promise<Session[]> {
|
|
@@ -28,4 +28,20 @@ export function submitPrompt(sessionId: string, userPrompt: string): Promise<Tur
|
|
|
28
28
|
|
|
29
29
|
export function cancelTurn(sessionId: string): Promise<{ success: boolean }> {
|
|
30
30
|
return apiPost<{ success: boolean }>(`/sessions/${sessionId}/cancel`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renameSession(sessionId: string, title: string): Promise<Session> {
|
|
34
|
+
return apiPatch<Session>(`/sessions/${sessionId}`, { title });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function markFinished(sessionId: string): Promise<Session> {
|
|
38
|
+
return apiPatch<Session>(`/sessions/${sessionId}`, { lifecycle: 'finished' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function reopenSession(sessionId: string): Promise<Session> {
|
|
42
|
+
return apiPatch<Session>(`/sessions/${sessionId}`, { lifecycle: 'working' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clearUnreviewed(sessionId: string): Promise<Session> {
|
|
46
|
+
return apiPatch<Session>(`/sessions/${sessionId}`, { has_unreviewed: 0 });
|
|
31
47
|
}
|
|
@@ -103,6 +103,7 @@ interface TurnCompletedPayload {
|
|
|
103
103
|
sessionId: string;
|
|
104
104
|
turnId: string;
|
|
105
105
|
usage: TokenUsage;
|
|
106
|
+
hasUnreviewed?: boolean;
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
interface TurnFailedPayload {
|
|
@@ -111,6 +112,11 @@ interface TurnFailedPayload {
|
|
|
111
112
|
error: string;
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
interface SessionTitlePayload {
|
|
116
|
+
sessionId: string;
|
|
117
|
+
title: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
const HANDLERS: Record<string, WsEventHandler> = {
|
|
115
121
|
'turn:started': (data) => {
|
|
116
122
|
const p = data as TurnEventPayload;
|
|
@@ -130,10 +136,14 @@ const HANDLERS: Record<string, WsEventHandler> = {
|
|
|
130
136
|
},
|
|
131
137
|
'turn:completed': (data) => {
|
|
132
138
|
const p = data as TurnCompletedPayload;
|
|
133
|
-
useStore.getState().onTurnCompleted(p.sessionId, p.turnId, p.usage);
|
|
139
|
+
useStore.getState().onTurnCompleted(p.sessionId, p.turnId, p.usage, p.hasUnreviewed);
|
|
134
140
|
},
|
|
135
141
|
'turn:failed': (data) => {
|
|
136
142
|
const p = data as TurnFailedPayload;
|
|
137
143
|
useStore.getState().onTurnFailed(p.sessionId, p.turnId, p.error);
|
|
138
144
|
},
|
|
145
|
+
'session:title': (data) => {
|
|
146
|
+
const p = data as SessionTitlePayload;
|
|
147
|
+
useStore.getState().onSessionTitle(p.sessionId, p.title);
|
|
148
|
+
},
|
|
139
149
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { apiGet, apiPost, apiDelete } from './client.ts';
|
|
2
|
+
import type { Workspace } from '../types/index.ts';
|
|
3
|
+
|
|
4
|
+
export function fetchWorkspaces(): Promise<Workspace[]> {
|
|
5
|
+
return apiGet<Workspace[]>('/workspaces');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createWorkspace(path: string, label?: string): Promise<Workspace> {
|
|
9
|
+
return apiPost<Workspace>('/workspaces', { path, label });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function hideWorkspace(path: string): Promise<{ success: boolean }> {
|
|
13
|
+
return apiPost<{ success: boolean }>('/workspaces/hide', { path });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function deleteWorkspace(id: string): Promise<{ success: boolean }> {
|
|
17
|
+
return apiDelete<{ success: boolean }>(`/workspaces/${id}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function pickDirectory(): Promise<{ path?: string; cancelled?: boolean }> {
|
|
21
|
+
const res = await fetch('/api/pick-directory');
|
|
22
|
+
if (res.status === 204) return { cancelled: true };
|
|
23
|
+
return res.json();
|
|
24
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Wrench, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { MemoItemCard } from './TurnContent.tsx';
|
|
4
|
+
import type { Item } from '../types/index.ts';
|
|
5
|
+
|
|
6
|
+
const TOOL_TYPES = new Set(['command_execution', 'file_change', 'mcp_tool_call', 'web_search']);
|
|
7
|
+
|
|
8
|
+
export function isToolItem(item: Item): boolean {
|
|
9
|
+
return TOOL_TYPES.has(item.type);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
items: Item[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function CollapsibleToolCalls({ items }: Props) {
|
|
17
|
+
const [expanded, setExpanded] = useState(false);
|
|
18
|
+
|
|
19
|
+
if (items.length === 0) return null;
|
|
20
|
+
|
|
21
|
+
const count = items.length;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="tool-calls">
|
|
25
|
+
<div
|
|
26
|
+
className="tool-calls-header"
|
|
27
|
+
onClick={() => setExpanded((e) => !e)}
|
|
28
|
+
>
|
|
29
|
+
<span className="tool-calls-icon">
|
|
30
|
+
<Wrench size={12} />
|
|
31
|
+
</span>
|
|
32
|
+
<span className="tool-calls-summary">
|
|
33
|
+
{count} tool call{count > 1 ? 's' : ''}
|
|
34
|
+
</span>
|
|
35
|
+
<ChevronRight
|
|
36
|
+
size={14}
|
|
37
|
+
className="tool-calls-chevron"
|
|
38
|
+
style={{ transform: expanded ? 'rotate(90deg)' : 'none' }}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
{expanded && (
|
|
42
|
+
<div className="tool-calls-body">
|
|
43
|
+
{items.map((item) => (
|
|
44
|
+
<MemoItemCard key={item.id} item={item} />
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -190,6 +190,17 @@ export default function CommandPalette() {
|
|
|
190
190
|
})}
|
|
191
191
|
</div>
|
|
192
192
|
)}
|
|
193
|
+
<div className="palette-footer">
|
|
194
|
+
<span className="palette-footer-hint">
|
|
195
|
+
<span className="kbd">↑</span><span className="kbd">↓</span> Navigate
|
|
196
|
+
</span>
|
|
197
|
+
<span className="palette-footer-hint">
|
|
198
|
+
<span className="kbd">↵</span> Select
|
|
199
|
+
</span>
|
|
200
|
+
<span className="palette-footer-hint">
|
|
201
|
+
<span className="kbd">Esc</span> Close
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
193
204
|
</div>
|
|
194
205
|
</div>
|
|
195
206
|
);
|