From f830079a0e8d9517b30129423f9d17b3ea98d25f Mon Sep 17 00:00:00 2001 From: "Wit, Floris De (178)" Date: Thu, 9 Jul 2026 16:15:35 +0200 Subject: [PATCH 1/5] feat(sessions): session-management completeness (fork, archive, approvals, pagination) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out the session lifecycle so a hub session is a fully manageable "terminal window" (README §6 req #4: list/resume/fork/archive/search) plus two UX/scale items. No new engine, no scheduling. One additive DB migration (id:4). Session fork (--fork-session): - POST /api/sessions/:id/fork copies the parent's config + event log and, on the fork's first turn, resumes the parent transcript under a NEW engine session id via --fork-session, leaving the parent untouched. A one-shot fork_pending flag (migration 4) is consumed on the first engine_meta. SessionManager.fork, EventsRepo.copyEvents, createForked, StartOptions.forkSession, SessionDto.parentId. Web: fork button in the sidebar. Verified keyless (echo) + live (Claude mints a new id and recalls the parent transcript). Archived-sessions view (backend already existed): - Sidebar "Show archived" toggle (lazy api.sessions(true)), archive/unarchive row action, render-time archivedAt filter, plus a title search filter. Approval affordances: - Surface the CLI's permission_suggestions as informational chips in the approval card (defensive rendering; no rule persistence). Threaded adapter -> event -> timeline -> ApprovalCard. Replay pagination: - Bounded initial WS replay (most recent N + replay_meta{hasMoreBefore}); older history pages backward over REST (/events?beforeId&limit). Forward catch-up on reconnect is unchanged, preserving the atomic replay-then-live invariant. Web: "Load earlier" with scroll anchoring. Tests: +fork and +pagination e2e (echo, keyless); +live fork test (gated). --- server/src/api/routes.ts | 12 +- server/src/api/ws.ts | 33 ++++- server/src/db/database.ts | 9 ++ server/src/db/repos.ts | 101 +++++++++++++++- .../src/engines/claude/ClaudeCodeAdapter.ts | 4 + server/src/engines/types.ts | 3 + server/src/sessions/SessionManager.ts | 33 +++++ server/src/sessions/SessionRuntime.ts | 4 + server/test/claude-live.test.ts | 46 +++++++ server/test/e2e.test.ts | 114 ++++++++++++++++++ shared/src/api.ts | 2 + shared/src/events.ts | 3 + shared/src/protocol.ts | 12 +- web/src/components/ApprovalCard.svelte | 36 +++++- web/src/components/ChatView.svelte | 37 +++++- web/src/components/SessionSidebar.svelte | 109 ++++++++++++++++- web/src/lib/api.ts | 6 + web/src/lib/format.ts | 33 +++++ web/src/lib/stores.ts | 27 +++++ web/src/lib/timeline.ts | 2 + web/src/lib/ws.ts | 30 ++++- 21 files changed, 638 insertions(+), 18 deletions(-) diff --git a/server/src/api/routes.ts b/server/src/api/routes.ts index 7407ae1..2c8f662 100644 --- a/server/src/api/routes.ts +++ b/server/src/api/routes.ts @@ -66,6 +66,11 @@ export function registerRoutes(app: FastifyInstance, deps: RouteDeps): void { return manager.create(body); }); + app.post('/api/sessions/:id/fork', async (req) => { + const { id } = req.params as { id: string }; + return manager.fork(id); + }); + app.get('/api/sessions/:id', async (req, reply) => { const { id } = req.params as { id: string }; const dto = manager.get(id); @@ -87,7 +92,12 @@ export function registerRoutes(app: FastifyInstance, deps: RouteDeps): void { app.get('/api/sessions/:id/events', async (req, reply) => { const { id } = req.params as { id: string }; if (!manager.get(id)) return reply.code(404).send({ error: 'not_found' }); - const q = req.query as { afterId?: string }; + const q = req.query as { afterId?: string; beforeId?: string; limit?: string }; + // beforeId => backward page (for "load earlier"); otherwise forward from afterId. + if (q.beforeId !== undefined) { + const limit = q.limit ? Math.min(Number(q.limit), 2000) : 500; + return events.listBefore(id, Number(q.beforeId), limit); + } return events.listAfter(id, q.afterId ? Number(q.afterId) : 0); }); diff --git a/server/src/api/ws.ts b/server/src/api/ws.ts index d28b53b..6c27968 100644 --- a/server/src/api/ws.ts +++ b/server/src/api/ws.ts @@ -15,6 +15,10 @@ interface WsDeps { manager: SessionManager; } +/** Events replayed on a bounded initial subscribe; older history pages over REST. */ +const DEFAULT_REPLAY_LIMIT = 500; +const MAX_REPLAY_LIMIT = 2000; + /** * WebSocket hub. Auth-by-first-message, then subscribe/replay per session: * persisted events stream from the DB cursor, live events fan out from the @@ -106,12 +110,33 @@ export function attachWebSocket(server: HttpServer, deps: WsDeps): WebSocketServ if (!deps.manager.get(msg.sessionId)) { throw new HubError('not_found', 'Session not found'); } - // Replay persisted history from the client's cursor, THEN mark the - // subscription live. Replay is synchronous (better-sqlite3), so no - // live event can interleave with it. - for (const e of deps.events.listAfter(msg.sessionId, msg.afterEventId ?? 0)) { + // Replay persisted history, THEN mark the subscription live. Replay is + // synchronous (better-sqlite3), so no live event can interleave with it. + // - afterEventId set => forward catch-up (full, bounded by what was missed). + // - omitted => bounded initial load of the most recent events; + // older history is fetched lazily over REST. + const afterProvided = typeof msg.afterEventId === 'number'; + let history: SessionEvent[]; + if (afterProvided) { + history = deps.events.listAfter(msg.sessionId, msg.afterEventId as number); + } else { + const limit = + msg.limit && msg.limit > 0 + ? Math.min(msg.limit, MAX_REPLAY_LIMIT) + : DEFAULT_REPLAY_LIMIT; + history = deps.events.listRecent(msg.sessionId, limit); + } + for (const e of history) { send({ type: 'event', sessionId: e.sessionId, eventId: e.id, ts: e.ts, event: e.event }); } + // Only the bounded initial load reports whether older history exists; + // forward catch-up must not overwrite the client's existing cursor. + if (!afterProvided) { + const oldestEventId = history.length ? history[0]!.id : null; + const hasMoreBefore = + oldestEventId !== null && deps.events.hasEventsBefore(msg.sessionId, oldestEventId); + send({ type: 'replay_meta', sessionId: msg.sessionId, hasMoreBefore, oldestEventId }); + } subscriptions.add(msg.sessionId); const dto = deps.manager.get(msg.sessionId); if (dto) send({ type: 'status', sessionId: dto.id, status: dto.status }); diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 1c1d4b4..dca6410 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -66,6 +66,15 @@ ALTER TABLE sessions ADD COLUMN engine_state TEXT NOT NULL DEFAULT 'new'; UPDATE sessions SET engine_state = CASE WHEN engine_session_id IS NOT NULL THEN 'hibernated' ELSE 'new' END; +`, + }, + { + // Session fork: lineage pointer + a one-shot flag consumed on the fork's + // first engine start (so --fork-session is passed exactly once). + id: 4, + sql: ` +ALTER TABLE sessions ADD COLUMN parent_session_id TEXT; +ALTER TABLE sessions ADD COLUMN fork_pending INTEGER NOT NULL DEFAULT 0; `, }, ]; diff --git a/server/src/db/repos.ts b/server/src/db/repos.ts index 369dd76..10e0a99 100644 --- a/server/src/db/repos.ts +++ b/server/src/db/repos.ts @@ -25,6 +25,8 @@ interface SessionRow { model: string | null; requested_model: string | null; settings: string | null; + parent_session_id: string | null; + fork_pending: number; } function toDto(r: SessionRow): SessionDto { @@ -45,6 +47,7 @@ function toDto(r: SessionRow): SessionDto { model: r.model, requestedModel: r.requested_model, settings: r.settings, + parentId: r.parent_session_id, }; } @@ -71,6 +74,59 @@ export class SessionsRepo { return this.get(s.id)!; } + /** + * Insert a forked session: seeds the parent's engine session id (so the first + * turn resumes it), marks it resumable-but-not-running (`hibernated`), and sets + * the one-shot `fork_pending` flag consumed on the fork's first engine start. + */ + createForked(s: { + id: string; + title: string; + engineId: string; + cwd: string; + permissionMode: PermissionMode | null; + model: string | null; + settings: string | null; + engineSessionId: string; + parentSessionId: string; + }): SessionDto { + const now = Date.now(); + this.db + .prepare( + `INSERT INTO sessions + (id, title, engine_id, engine_session_id, cwd, permission_mode, requested_model, settings, + status, engine_state, fork_pending, parent_session_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'idle', 'hibernated', 1, ?, ?, ?)`, + ) + .run( + s.id, + s.title, + s.engineId, + s.engineSessionId, + s.cwd, + s.permissionMode, + s.model, + s.settings, + s.parentSessionId, + now, + now, + ); + return this.get(s.id)!; + } + + /** Whether a forked session still needs --fork-session on its next engine start. */ + forkPending(id: string): boolean { + const row = this.db.prepare('SELECT fork_pending FROM sessions WHERE id = ?').get(id) as + | { fork_pending: number } + | undefined; + return !!row && row.fork_pending !== 0; + } + + /** Consume the one-shot fork flag once the fork has its own engine session id. */ + clearForkPending(id: string): void { + this.db.prepare('UPDATE sessions SET fork_pending = 0 WHERE id = ?').run(id); + } + get(id: string): SessionDto | undefined { const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as | SessionRow @@ -170,6 +226,10 @@ interface EventRow { payload: string; } +function rowToEvent(r: EventRow): SessionEvent { + return { id: r.id, sessionId: r.session_id, ts: r.ts, event: JSON.parse(r.payload) as NormalizedEvent }; +} + export class EventsRepo { constructor(private db: Database.Database) {} @@ -181,16 +241,45 @@ export class EventsRepo { return { id: Number(info.lastInsertRowid), sessionId, ts, event }; } + /** Copy a session's event log into another session (used by fork), in id order. */ + copyEvents(fromSessionId: string, toSessionId: string): void { + this.db + .prepare( + `INSERT INTO events (session_id, ts, type, payload) + SELECT ?, ts, type, payload FROM events WHERE session_id = ? ORDER BY id`, + ) + .run(toSessionId, fromSessionId); + } + listAfter(sessionId: string, afterId = 0): SessionEvent[] { const rows = this.db .prepare('SELECT * FROM events WHERE session_id = ? AND id > ? ORDER BY id') .all(sessionId, afterId) as EventRow[]; - return rows.map((r) => ({ - id: r.id, - sessionId: r.session_id, - ts: r.ts, - event: JSON.parse(r.payload) as NormalizedEvent, - })); + return rows.map(rowToEvent); + } + + /** The most recent `limit` events (ascending) — the bounded initial replay window. */ + listRecent(sessionId: string, limit: number): SessionEvent[] { + const rows = this.db + .prepare('SELECT * FROM events WHERE session_id = ? ORDER BY id DESC LIMIT ?') + .all(sessionId, limit) as EventRow[]; + return rows.reverse().map(rowToEvent); + } + + /** Up to `limit` events immediately before `beforeId` (ascending) — back-paging. */ + listBefore(sessionId: string, beforeId: number, limit: number): SessionEvent[] { + const rows = this.db + .prepare('SELECT * FROM events WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?') + .all(sessionId, beforeId, limit) as EventRow[]; + return rows.reverse().map(rowToEvent); + } + + /** Whether any event exists before `beforeId` (used to flag "load earlier"). */ + hasEventsBefore(sessionId: string, beforeId: number): boolean { + const r = this.db + .prepare('SELECT 1 FROM events WHERE session_id = ? AND id < ? LIMIT 1') + .get(sessionId, beforeId); + return r !== undefined; } /** diff --git a/server/src/engines/claude/ClaudeCodeAdapter.ts b/server/src/engines/claude/ClaudeCodeAdapter.ts index 7bef54b..e8f1495 100644 --- a/server/src/engines/claude/ClaudeCodeAdapter.ts +++ b/server/src/engines/claude/ClaudeCodeAdapter.ts @@ -87,6 +87,9 @@ class ClaudeRun implements EngineRun { if (opts.settings) args.push('--settings', opts.settings); args.push(...extraArgs); if (opts.resumeEngineSessionId) args.push('--resume', opts.resumeEngineSessionId); + // Fork the resumed transcript into a new engine session, leaving the parent + // intact. Only meaningful together with --resume, and only on the first start. + if (opts.forkSession && opts.resumeEngineSessionId) args.push('--fork-session'); const env: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; for (const k of SCRUBBED_ENV) delete env[k]; @@ -161,6 +164,7 @@ class ClaudeRun implements EngineRun { requestId: req.request_id, toolName: req.request.tool_name ?? 'unknown', input: req.request.input, + permissionSuggestions: req.request.permission_suggestions, }); } else { // Unknown control request: ack so the CLI doesn't hang. diff --git a/server/src/engines/types.ts b/server/src/engines/types.ts index 9a1f90d..9dcf942 100644 --- a/server/src/engines/types.ts +++ b/server/src/engines/types.ts @@ -13,6 +13,9 @@ export interface StartOptions { settings?: string | null; /** Engine-native session id to resume, if any. */ resumeEngineSessionId?: string; + /** Fork the resumed session into a new engine session (Claude --fork-session). + * Set only on the first start of a forked session; ignored without a resume id. */ + forkSession?: boolean; env?: Record; } diff --git a/server/src/sessions/SessionManager.ts b/server/src/sessions/SessionManager.ts index 2074e8d..1051f5f 100644 --- a/server/src/sessions/SessionManager.ts +++ b/server/src/sessions/SessionManager.ts @@ -91,6 +91,39 @@ export class SessionManager { return dto; } + /** + * Fork a session: a new session that inherits the parent's config and full + * event log, and resumes the parent's engine transcript on its first turn + * under a new engine session id (via the adapter's --fork-session), leaving + * the parent untouched. + */ + fork(parentId: string): SessionDto { + const parent = this.sessions.get(parentId); + if (!parent) throw new HubError('not_found', 'Session not found'); + if (!parent.engineSessionId) { + throw new HubError('cannot_fork', 'Session has no transcript to fork yet (it has not run).'); + } + const engine = this.engines.descriptor(parent.engineId); + if (!engine) throw new HubError('unknown_engine', `Unknown engine: ${parent.engineId}`); + if (!engine.available) { + throw new HubError('engine_unavailable', engine.unavailableReason ?? 'Engine unavailable'); + } + const dto = this.sessions.createForked({ + id: randomUUID(), + title: `${parent.title} (fork)`, + engineId: parent.engineId, + cwd: parent.cwd, + permissionMode: parent.permissionMode, + model: parent.requestedModel, + settings: parent.settings, + engineSessionId: parent.engineSessionId, + parentSessionId: parent.id, + }); + this.events.copyEvents(parent.id, dto.id); + this.bus.emit('session_upsert', dto); + return dto; + } + get(id: string): SessionDto | undefined { return this.sessions.get(id); } diff --git a/server/src/sessions/SessionRuntime.ts b/server/src/sessions/SessionRuntime.ts index 3f3ab34..5aedf14 100644 --- a/server/src/sessions/SessionRuntime.ts +++ b/server/src/sessions/SessionRuntime.ts @@ -73,6 +73,7 @@ export class SessionRuntime { model: this.session.requestedModel, settings: this.session.settings, resumeEngineSessionId: this.session.engineSessionId ?? undefined, + forkSession: this.sessions.forkPending(this.session.id), }); this.run = run; if (run.pid) this.sessions.setPid(this.session.id, run.pid); @@ -113,6 +114,9 @@ export class SessionRuntime { // per resume, and only the latest one resumes correctly. this.sessions.setEngineSessionId(this.session.id, e.engineSessionId); this.session.engineSessionId = e.engineSessionId; + // A forked session has now resumed+forked into its own engine session; + // consume the one-shot flag so later resumes don't re-fork. + this.sessions.clearForkPending(this.session.id); } if (e.model) this.sessions.setMeta(this.session.id, { model: e.model }); this.persist(e); diff --git a/server/test/claude-live.test.ts b/server/test/claude-live.test.ts index 5b59736..cc391fc 100644 --- a/server/test/claude-live.test.ts +++ b/server/test/claude-live.test.ts @@ -79,4 +79,50 @@ live('ClaudeCodeAdapter (live CLI)', () => { expect(texts.join(' ')).toContain('hub-live-test'); }, 180_000); + + it('fork-session resumes the parent transcript under a NEW engine session id', async () => { + // Parent turn: plant a codeword the model must recall later. + const run = adapter.start({ + sessionId: 'live-fork-parent', + cwd: workspace, + permissionMode: 'bypassPermissions', + }); + let parentSid: string | undefined; + const done1 = new Promise((resolve) => { + run.onEvent((e) => { + if (e.type === 'engine_meta' && e.engineSessionId) parentSid = e.engineSessionId; + if (e.type === 'done') resolve(); + }); + }); + run.sendUserMessage('Remember this secret codeword: BANANA-42. Reply with just "ok".'); + await done1; + run.dispose(); + await new Promise((r) => setTimeout(r, 500)); + expect(parentSid).toBeTruthy(); + + // Fork: resume the parent transcript, but --fork-session writes into a NEW id. + const fork = adapter.start({ + sessionId: 'live-fork-child', + cwd: workspace, + permissionMode: 'bypassPermissions', + resumeEngineSessionId: parentSid, + forkSession: true, + }); + const forkTexts: string[] = []; + let forkSid: string | undefined; + const done2 = new Promise((resolve) => { + fork.onEvent((e) => { + if (e.type === 'engine_meta' && e.engineSessionId) forkSid = e.engineSessionId; + if (e.type === 'message' && e.role === 'assistant') forkTexts.push(e.text); + if (e.type === 'done') resolve(); + }); + }); + fork.sendUserMessage('What was the secret codeword I told you? Reply with just the codeword.'); + await done2; + fork.dispose(); + + expect(forkSid).toBeTruthy(); + expect(forkSid).not.toBe(parentSid); // forked into its own engine session + expect(forkTexts.join(' ')).toContain('BANANA-42'); // parent transcript carried over + }, 180_000); }); diff --git a/server/test/e2e.test.ts b/server/test/e2e.test.ts index 0d7ec13..365223b 100644 --- a/server/test/e2e.test.ts +++ b/server/test/e2e.test.ts @@ -387,4 +387,118 @@ describe('end-to-end with echo engine', () => { c.close(); await server.close(); }, 15_000); + + it('forks a session: copies its events, runs its own turn, leaves the parent intact', async () => { + server = await buildServer(testConfig(dataDir, workspace)); + const port = server.address.port; + const c = new Client(port); + await c.open(); + c.send({ type: 'auth', token: TOKEN }); + await c.next((m) => m.type === 'auth_ok'); + + // A session that has never run has no transcript to fork. + const fresh = await api(port, 'POST', '/api/sessions', { + engineId: 'echo', + cwd: workspace, + permissionMode: 'bypassPermissions', + }); + await expect(api(port, 'POST', `/api/sessions/${fresh.id}/fork`)).rejects.toThrow(/400/); + + // Run one turn so it has an engine transcript. + c.send({ type: 'subscribe', sessionId: fresh.id }); + c.send({ type: 'user_message', sessionId: fresh.id, text: 'parent turn' }); + await c.next( + (m) => m.type === 'event' && m.sessionId === fresh.id && m.event.type === 'done', + 10_000, + ); + const parent = await api(port, 'GET', `/api/sessions/${fresh.id}`); + expect(parent.engineSessionId).toBeTruthy(); + const parentTimeline = c.events(parent.id); + + // Fork it: lineage + seeded engine session id + resumable-but-not-live. + const forkDto = await api(port, 'POST', `/api/sessions/${parent.id}/fork`); + expect(forkDto.parentId).toBe(parent.id); + expect(forkDto.engineSessionId).toBe(parent.engineSessionId); + expect(forkDto.engineState).toBe('hibernated'); + + // The fork opens with the parent's copied scrollback. + const cf = new Client(port); + await cf.open(); + cf.send({ type: 'auth', token: TOKEN }); + await cf.next((m) => m.type === 'auth_ok'); + cf.send({ type: 'subscribe', sessionId: forkDto.id, afterEventId: 0 }); + await cf.next( + (m) => m.type === 'event' && m.sessionId === forkDto.id && m.event.type === 'done', + 10_000, + ); + const forkReplay = cf.events(forkDto.id); + expect(forkReplay.map((e) => e.event.type)).toEqual(parentTimeline.map((e) => e.event.type)); + + // Running a turn in the fork appends only to the fork; the parent is untouched. + cf.send({ type: 'user_message', sessionId: forkDto.id, text: 'fork turn' }); + await cf.next( + (m) => + m.type === 'event' && + m.sessionId === forkDto.id && + m.event.type === 'done' && + m.eventId > forkReplay[forkReplay.length - 1]!.id, + 10_000, + ); + const parentAfter = await api(port, 'GET', `/api/sessions/${parent.id}/events`); + expect(parentAfter).toHaveLength(parentTimeline.length); + + cf.close(); + c.close(); + await server.close(); + }, 20_000); + + it('bounded initial replay returns the recent window and pages older events over REST', async () => { + server = await buildServer(testConfig(dataDir, workspace)); + const port = server.address.port; + const c = new Client(port); + await c.open(); + c.send({ type: 'auth', token: TOKEN }); + await c.next((m) => m.type === 'auth_ok'); + + const session = await api(port, 'POST', '/api/sessions', { + engineId: 'echo', + cwd: workspace, + permissionMode: 'bypassPermissions', + }); + c.send({ type: 'subscribe', sessionId: session.id }); + c.send({ type: 'user_message', sessionId: session.id, text: 'one turn' }); + await c.next( + (m) => m.type === 'event' && m.sessionId === session.id && m.event.type === 'done', + 10_000, + ); + + const all = await api(port, 'GET', `/api/sessions/${session.id}/events`); + expect(all.length).toBeGreaterThan(3); + + // A fresh subscribe with a small limit replays only the most recent window. + const c2 = new Client(port); + await c2.open(); + c2.send({ type: 'auth', token: TOKEN }); + await c2.next((m) => m.type === 'auth_ok'); + c2.send({ type: 'subscribe', sessionId: session.id, limit: 3 }); + const meta = (await c2.next( + (m) => m.type === 'replay_meta' && m.sessionId === session.id, + )) as Extract; + const replayed = c2.events(session.id); + expect(replayed.map((e) => e.id)).toEqual(all.slice(-3).map((e) => e.id)); + expect(meta.hasMoreBefore).toBe(true); + expect(meta.oldestEventId).toBe(all[all.length - 3]!.id); + + // The older events page in backward over REST. + const earlier = await api( + port, + 'GET', + `/api/sessions/${session.id}/events?beforeId=${meta.oldestEventId}&limit=100`, + ); + expect(earlier.map((e) => e.id)).toEqual(all.slice(0, all.length - 3).map((e) => e.id)); + + c2.close(); + c.close(); + await server.close(); + }, 15_000); }); diff --git a/shared/src/api.ts b/shared/src/api.ts index cb901a0..1cd3aaa 100644 --- a/shared/src/api.ts +++ b/shared/src/api.ts @@ -37,6 +37,8 @@ export interface SessionDto { requestedModel: string | null; /** Optional --settings override (file path or inline JSON); null = inherit. */ settings: string | null; + /** If this session was forked from another, the parent's id (else null). */ + parentId: string | null; } export interface CreateSessionRequest { diff --git a/shared/src/events.ts b/shared/src/events.ts index efff672..1a60111 100644 --- a/shared/src/events.ts +++ b/shared/src/events.ts @@ -73,6 +73,9 @@ export type NormalizedEvent = requestId: string; toolName: string; input: unknown; + /** Engine-suggested permission updates (e.g. allow-rules the CLI proposes). + * Loosely typed since the CLI shape isn't pinned; surfaced as context only. */ + permissionSuggestions?: unknown; } | { type: 'approval_resolved'; diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index 6f59a6f..e74d315 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -9,7 +9,9 @@ import type { SessionDto } from './api.js'; export type ClientMsg = | { type: 'auth'; token: string } - | { type: 'subscribe'; sessionId: string; afterEventId?: number } + /** afterEventId set = forward catch-up (full). Omitted = bounded initial load + * of the most recent `limit` events (server-clamped). */ + | { type: 'subscribe'; sessionId: string; afterEventId?: number; limit?: number } | { type: 'unsubscribe'; sessionId: string } | { type: 'user_message'; sessionId: string; text: string } | { @@ -36,6 +38,14 @@ export type ServerMsg = delta: string; } | { type: 'status'; sessionId: string; status: SessionStatus } + /** Sent once after a bounded initial replay: whether older events exist and + * the oldest event id currently sent (the cursor for "load earlier"). */ + | { + type: 'replay_meta'; + sessionId: string; + hasMoreBefore: boolean; + oldestEventId: number | null; + } | { type: 'session_upsert'; session: SessionDto } | { type: 'session_deleted'; sessionId: string } | { type: 'error'; sessionId?: string; code: string; message: string }; diff --git a/web/src/components/ApprovalCard.svelte b/web/src/components/ApprovalCard.svelte index 01c6d72..632eff3 100644 --- a/web/src/components/ApprovalCard.svelte +++ b/web/src/components/ApprovalCard.svelte @@ -1,7 +1,7 @@ + +
+
+

Let's build.

+

Point a session at a project, optionally isolate it, and give it a task.

+ + {#if engines.length && !availableEngines.length} +
+ No engine is available. Install and authenticate a CLI (e.g. claude login), + then reload. +
+ {/if} + + {#if recentProjects.length} +
+ Recent projects +
+ {#each recentProjects as p (p)} + + {/each} +
+
+ {/if} + + + + {#if showBrowse && browseData} +
+
+ {#if browseData.parent !== null || browseData.path} + + {/if} + {browseData.path || '(workspace roots)'} +
+
+ {#each browseData.entries as entry (entry.path)} +
+ + +
+ {:else} +

no subdirectories

+ {/each} +
+ {#if browseData.path} + + {/if} +
+ {/if} + + {#if git?.isRepo} +
+ on branch: {git.currentBranch ?? '(detached)'} + + {#if useWorktree} + + {/if} +
+ {/if} + + + +
+ + {#if showAdvanced} +
+ {#if showEnginePicker} + + {/if} + + + + + + +
+ {/if} +
+ + {#if error}

{error}

{/if} + +
+ +
+
+
+ + diff --git a/web/src/components/NewSessionDialog.svelte b/web/src/components/NewSessionDialog.svelte deleted file mode 100644 index ff27548..0000000 --- a/web/src/components/NewSessionDialog.svelte +++ /dev/null @@ -1,308 +0,0 @@ - - -
e.target === e.currentTarget && close()} - onkeydown={(e) => e.key === 'Escape' && close()} - role="presentation" -> -
-

New session

- - - - - - {#if showBrowse && browseData} -
-
- {#if browseData.parent !== null || browseData.path} - - {/if} - {browseData.path || '(workspace roots)'} -
-
- {#each browseData.entries as entry (entry.path)} -
- - -
- {:else} -

no subdirectories

- {/each} -
- {#if browseData.path} - - {/if} -
- {/if} - -
- Permission mode - {#each PERMISSION_MODES as mode (mode.value ?? 'inherit')} - - {/each} -
- - - - - - {#if error}

{error}

{/if} - -
- - -
-
-
- - diff --git a/web/src/components/SessionSidebar.svelte b/web/src/components/SessionSidebar.svelte index 0011c09..bdf3d20 100644 --- a/web/src/components/SessionSidebar.svelte +++ b/web/src/components/SessionSidebar.svelte @@ -10,11 +10,9 @@ import { api } from '../lib/api.js'; import { disconnect, subscribeSession } from '../lib/ws.js'; import { engineStateLabel, isDangerMode, relativeTime } from '../lib/format.js'; - import NewSessionDialog from './NewSessionDialog.svelte'; let { showToast }: { showToast: (m: string) => void } = $props(); - let showNew = $state(false); let renamingId: string | null = $state(null); let renameValue = $state(''); let showArchived = $state(false); @@ -111,7 +109,7 @@ - +
@@ -208,16 +206,6 @@ -{#if showNew} - (showNew = false)} - created={(id) => { - showNew = false; - select(id); - }} - /> -{/if} -