From 604d70df9c491defdc1c3537e07072658fcda840 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:39:03 +0100 Subject: [PATCH 001/128] feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#893) Promotes scratchlist persistence from per-device localStorage to a hub- backed typed table so entries follow the operator across devices. v1 panel UI / FUE / shortcut / styling are deliberately unchanged - this is a backend + sync-layer feature. Hub side - New `session_scratchlist` typed table (sessionId, entryId, text, createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from sessions. Schema bumped V9 -> V10; idempotent migration added to the legacy + step ladders. - REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed through the existing `requireSessionFromParam` guard so namespace / ownership enforcement is identical to other session-scoped routes. - Per-session 200-entry cap enforced on POST. Duplicate entryId reported idempotently (200) so the migration retry path is safe. - `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`; every successful mutation emits a `session-updated` SSE patch with the token. (Following operator's piggyback decision; aligns with the parallel #884 patch-shape extension.) Web side - Hub becomes source of truth via TanStack Query (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error. - Silent first-load migration: existing localStorage entries are pushed to the hub preserving id + createdAt, and a one-time banner (mirroring `CursorMigrationBanner`) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent. - SSE handler queues a `scratchlist` invalidation when the patch carries `scratchlistUpdatedAt`, so cross-device + cross-tab updates land within an SSE round-trip. - Delete-session confirm copy now includes a count of scratchlist entries that will be cascade-deleted. Out of scope (separate tracking issue #894): "delete with summarize-and- migrate" UX flow. Tests - Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes (happy path + 400/403/404/409), SyncEngine SSE emission. - Web: hook covers initial fetch, optimistic add/delete/update with rollback, localStorage migration + banner, cap enforcement, local-only reorder. Banner component renders only on `'completed'`. - Existing Playwright e2e (10 tests, panel UI regression) all pass unchanged. Co-authored-by: Cursor --- hub/src/store/index.ts | 58 ++- hub/src/store/migration-v10.test.ts | 329 +++++++++++++ hub/src/store/scratchlist.ts | 181 +++++++ hub/src/store/scratchlistStore.ts | 52 ++ hub/src/store/types.ts | 8 + hub/src/sync/sessionCache.ts | 29 ++ hub/src/sync/syncEngine-scratchlist.test.ts | 212 ++++++++ hub/src/sync/syncEngine.ts | 86 ++++ .../web/routes/sessions-scratchlist.test.ts | 347 +++++++++++++ hub/src/web/routes/sessions.ts | 128 +++++ shared/src/apiTypes.ts | 44 ++ shared/src/schemas.ts | 29 +- web/src/api/client.ts | 54 ++ .../ScratchlistMigrationBanner.test.tsx | 60 +++ .../ScratchlistMigrationBanner.tsx | 64 +++ web/src/components/SessionChat.tsx | 24 +- web/src/components/SessionHeader.tsx | 17 +- web/src/hooks/useSSE.ts | 28 +- web/src/lib/locales/en.ts | 5 + web/src/lib/locales/zh-CN.ts | 5 + web/src/lib/query-keys.ts | 1 + web/src/lib/use-hub-scratchlist.test.tsx | 360 ++++++++++++++ web/src/lib/use-hub-scratchlist.ts | 465 ++++++++++++++++++ web/src/lib/use-scratchlist-count.ts | 30 ++ 24 files changed, 2605 insertions(+), 11 deletions(-) create mode 100644 hub/src/store/migration-v10.test.ts create mode 100644 hub/src/store/scratchlist.ts create mode 100644 hub/src/store/scratchlistStore.ts create mode 100644 hub/src/sync/syncEngine-scratchlist.test.ts create mode 100644 hub/src/web/routes/sessions-scratchlist.test.ts create mode 100644 web/src/components/AssistantChat/ScratchlistMigrationBanner.test.tsx create mode 100644 web/src/components/AssistantChat/ScratchlistMigrationBanner.tsx create mode 100644 web/src/lib/use-hub-scratchlist.test.tsx create mode 100644 web/src/lib/use-hub-scratchlist.ts create mode 100644 web/src/lib/use-scratchlist-count.ts diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 9500490543..af6e923f47 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { MachineStore } from './machineStore' import { MessageStore } from './messageStore' import { PushStore } from './pushStore' +import { ScratchlistStore } from './scratchlistStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' @@ -12,6 +13,7 @@ export type { StoredMachine, StoredMessage, StoredPushSubscription, + StoredScratchlistEntry, StoredSession, StoredUser, VersionedUpdateResult @@ -20,16 +22,18 @@ export type { CancelQueuedMessageResult, LookupQueuedMessageResult } from './mes export { MachineStore } from './machineStore' export { MessageStore } from './messageStore' export { PushStore } from './pushStore' +export { ScratchlistStore } from './scratchlistStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 9 +const SCHEMA_VERSION: number = 10 const REQUIRED_TABLES = [ 'sessions', 'machines', 'messages', 'users', - 'push_subscriptions' + 'push_subscriptions', + 'session_scratchlist' ] as const export class Store { @@ -42,6 +46,7 @@ export class Store { readonly messages: MessageStore readonly users: UserStore readonly push: PushStore + readonly scratchlist: ScratchlistStore /** * Filesystem path of the underlying SQLite database, or ':memory:' for @@ -92,6 +97,7 @@ export class Store { this.messages = new MessageStore(this.db) this.users = new UserStore(this.db) this.push = new PushStore(this.db) + this.scratchlist = new ScratchlistStore(this.db) } close(): void { @@ -123,6 +129,7 @@ export class Store { 6: () => this.migrateFromV6ToV7(), 7: () => this.migrateFromV7ToV8(), 8: () => this.migrateFromV8ToV9(), + 9: () => this.migrateFromV9ToV10(), }) if (currentVersion === 0) { @@ -250,6 +257,18 @@ export class Store { UNIQUE(namespace, endpoint) ); CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); + + CREATE TABLE IF NOT EXISTS session_scratchlist ( + session_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, entry_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created + ON session_scratchlist(session_id, created_at DESC); `) } @@ -425,6 +444,41 @@ export class Store { `) } + /** + * tiann/hapi#893 (scratchlist v2): introduce the per-session + * `session_scratchlist` typed table. Operator-decided schema choice + * over an opaque metadata blob - the eventual overseer-context use + * case wants `(sessionId, createdAt)` queryability without parsing + * JSON. + * + * Idempotent via `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT + * EXISTS`. Cascade-delete from `sessions(id)` handles delete-session + * cleanup. No data backfill: pre-v10 hubs never had this data; the + * web client's first-run migration (`hapi.scratchlist.v2.migrated.*` + * flag) pushes any existing `localStorage` entries up via the REST + * endpoint. + * + * Rollback: `DROP TABLE session_scratchlist; PRAGMA user_version = 9;` + * - the table is independent, so the drop is safe and loses only the + * v2 hub-side entries (web client retains its localStorage offline + * cache). + */ + private migrateFromV9ToV10(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_scratchlist ( + session_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, entry_id), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created + ON session_scratchlist(session_id, created_at DESC); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v10.test.ts b/hub/src/store/migration-v10.test.ts new file mode 100644 index 0000000000..daed5a5633 --- /dev/null +++ b/hub/src/store/migration-v10.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Store } from './index' + +/** + * Tests for V9→V10 schema migration: introduces the `session_scratchlist` + * typed table for tiann/hapi#893 (scratchlist v2 hub sync). Mirrors the + * pattern in `migration-v9.test.ts`. + * + * The new table is independent (no backfill from existing rows) so the + * tests focus on: + * - presence on fresh DBs + * - presence on multi-hop legacy DBs (V6/V7/V8/V9 → V10) + * - idempotency on reopen + * - foreign-key cascade-delete from sessions(id) + * - the supporting (session_id, created_at) index + * - basic CRUD operations through the ScratchlistStore wrapper + */ +describe('Store V9→V10 migration: session_scratchlist table', () => { + it('fresh DB has session_scratchlist table with expected columns', () => { + const store = new Store(':memory:') + const cols = getColumns(store, 'session_scratchlist') + expect(cols).toContain('session_id') + expect(cols).toContain('entry_id') + expect(cols).toContain('text') + expect(cols).toContain('created_at') + expect(cols).toContain('updated_at') + }) + + it('fresh DB has the (session_id, created_at) index', () => { + const store = new Store(':memory:') + const db: Database = (store as unknown as { db: Database }).db + const rows = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_session_scratchlist_session_created'" + ).all() as Array<{ name: string }> + expect(rows).toHaveLength(1) + }) + + it('V9 DB migrates to V10 via Store: session_scratchlist created', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-test-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV9Schema(db) + db.exec('PRAGMA user_version = 9') + db.exec(`INSERT INTO sessions (id, namespace, created_at, updated_at, seq) + VALUES ('s1', 'default', 1000, 1000, 0)`) + db.close() + + store = new Store(dbPath) + const cols = getColumns(store, 'session_scratchlist') + expect(cols).toContain('session_id') + expect(cols).toContain('text') + + // Existing session row remains intact through the migration. + const sessions = (store as unknown as { db: Database }).db.prepare( + 'SELECT id FROM sessions' + ).all() as Array<{ id: string }> + expect(sessions.map((r) => r.id)).toEqual(['s1']) + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('V8 DB migrates to V10 (multi-hop V8→V9→V10)', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v8-to-v10-')) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV9Schema(db) // V9 is a superset of V8's tables for our purposes + db.exec('PRAGMA user_version = 8') + db.close() + + store = new Store(dbPath) + // After ladder runs, both v8→v9 (scheduled_at) AND v9→v10 (table) applied. + const messageCols = getColumns(store, 'messages') + expect(messageCols).toContain('scheduled_at') + const scratchCols = getColumns(store, 'session_scratchlist') + expect(scratchCols).toContain('entry_id') + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('V10 DB reopen is idempotent: schema unchanged', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-idempotent-')) + const dbPath = join(dir, 'test.db') + let store1: Store | undefined + let store2: Store | undefined + try { + store1 = new Store(dbPath) + const cols1 = getColumns(store1, 'session_scratchlist') + + store2 = new Store(dbPath) + const cols2 = getColumns(store2, 'session_scratchlist') + expect(cols2).toEqual(cols1) + } finally { + store2?.close() + store1?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('cascade-delete: scratchlist entries are removed when their session is deleted', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + const create1 = store.scratchlist.create(session.id, 'note one') + const create2 = store.scratchlist.create(session.id, 'note two') + expect(create1.outcome).toBe('created') + expect(create2.outcome).toBe('created') + expect(store.scratchlist.count(session.id)).toBe(2) + + await store.sessions.deleteSession(session.id, 'default') + expect(store.scratchlist.count(session.id)).toBe(0) + }) +}) + +describe('ScratchlistStore: CRUD through the typed-table wrapper', () => { + function setup() { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + return { store, sessionId: session.id } + } + + it('create returns the canonical row and assigns an entryId when omitted', () => { + const { store, sessionId } = setup() + const result = store.scratchlist.create(sessionId, 'hello') + if (result.outcome !== 'created') { + throw new Error(`Expected created, got ${result.outcome}`) + } + expect(result.entry.text).toBe('hello') + expect(result.entry.entryId).toMatch(/[0-9a-f-]{8,}/) + expect(result.entry.createdAt).toBeGreaterThan(0) + expect(result.entry.updatedAt).toBe(result.entry.createdAt) + }) + + it('create preserves caller-supplied entryId and createdAt for migration path', () => { + const { store, sessionId } = setup() + const result = store.scratchlist.create(sessionId, 'migrated', { + entryId: 'legacy-id-1', + createdAt: 12345, + }) + if (result.outcome !== 'created') throw new Error(`Expected created, got ${result.outcome}`) + expect(result.entry.entryId).toBe('legacy-id-1') + expect(result.entry.createdAt).toBe(12345) + // updatedAt is always Date.now() on insert; differs from createdAt when caller supplies an old timestamp. + expect(result.entry.updatedAt).toBeGreaterThan(12345) + }) + + it('create with an existing entryId is reported as duplicate and returns the existing row', () => { + const { store, sessionId } = setup() + const first = store.scratchlist.create(sessionId, 'first', { entryId: 'dup-id' }) + if (first.outcome !== 'created') throw new Error(`Expected created`) + const second = store.scratchlist.create(sessionId, 'second', { entryId: 'dup-id' }) + if (second.outcome !== 'duplicate') { + throw new Error(`Expected duplicate, got ${second.outcome}`) + } + expect(second.entry.text).toBe('first') + }) + + it('create against a non-existent session reports session-not-found (not a SQLite error)', () => { + const store = new Store(':memory:') + const result = store.scratchlist.create('does-not-exist', 'orphan') + expect(result.outcome).toBe('session-not-found') + }) + + it('list returns entries in createdAt DESC order (newest first)', () => { + const { store, sessionId } = setup() + const a = store.scratchlist.create(sessionId, 'oldest', { entryId: 'a', createdAt: 1000 }) + const b = store.scratchlist.create(sessionId, 'middle', { entryId: 'b', createdAt: 2000 }) + const c = store.scratchlist.create(sessionId, 'newest', { entryId: 'c', createdAt: 3000 }) + expect(a.outcome).toBe('created') + expect(b.outcome).toBe('created') + expect(c.outcome).toBe('created') + const entries = store.scratchlist.list(sessionId) + expect(entries.map((e) => e.entryId)).toEqual(['c', 'b', 'a']) + }) + + it('update bumps updated_at without touching createdAt; returns null for missing entries', () => { + const { store, sessionId } = setup() + const created = store.scratchlist.create(sessionId, 'before', { + entryId: 'u1', + createdAt: 1000, + }) + if (created.outcome !== 'created') throw new Error('Expected created') + + const updated = store.scratchlist.update(sessionId, 'u1', 'after') + expect(updated).not.toBeNull() + expect(updated!.text).toBe('after') + expect(updated!.createdAt).toBe(1000) + expect(updated!.updatedAt).toBeGreaterThan(1000) + + const missing = store.scratchlist.update(sessionId, 'does-not-exist', 'noop') + expect(missing).toBeNull() + }) + + it('delete returns true when the row existed, false otherwise', () => { + const { store, sessionId } = setup() + store.scratchlist.create(sessionId, 'doomed', { entryId: 'd1' }) + expect(store.scratchlist.delete(sessionId, 'd1')).toBe(true) + expect(store.scratchlist.delete(sessionId, 'd1')).toBe(false) + }) + + it('count tracks current rows', () => { + const { store, sessionId } = setup() + expect(store.scratchlist.count(sessionId)).toBe(0) + store.scratchlist.create(sessionId, 'a', { entryId: 'a' }) + store.scratchlist.create(sessionId, 'b', { entryId: 'b' }) + expect(store.scratchlist.count(sessionId)).toBe(2) + store.scratchlist.delete(sessionId, 'a') + expect(store.scratchlist.count(sessionId)).toBe(1) + }) + + it('entries from session A are not visible to session B', () => { + const store = new Store(':memory:') + const a = store.sessions.getOrCreateSession('a', { path: '/a' }, null, 'default') + const b = store.sessions.getOrCreateSession('b', { path: '/b' }, null, 'default') + store.scratchlist.create(a.id, 'A note', { entryId: 'shared-id' }) + expect(store.scratchlist.list(b.id)).toEqual([]) + expect(store.scratchlist.get(a.id, 'shared-id')).not.toBeNull() + expect(store.scratchlist.get(b.id, 'shared-id')).toBeNull() + }) +}) + +function getColumns(store: Store, table: string): string[] { + const db: Database = (store as unknown as { db: Database }).db + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> + return rows.map((r) => r.name) +} + +/** + * V9 schema: full pre-V10 shape. Used to seed legacy DBs to verify the + * V9→V10 step adds the new table without disturbing existing rows. + */ +function createV9Schema(db: Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + tag TEXT, + namespace TEXT NOT NULL DEFAULT 'default', + machine_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + agent_state TEXT, + agent_state_version INTEGER DEFAULT 1, + model TEXT, + model_reasoning_effort TEXT, + effort TEXT, + todos TEXT, + todos_updated_at INTEGER, + team_state TEXT, + team_state_updated_at INTEGER, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag); + CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace); + + CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + metadata_version INTEGER DEFAULT 1, + runner_state TEXT, + runner_state_version INTEGER DEFAULT 1, + active INTEGER DEFAULT 0, + active_at INTEGER, + seq INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + seq INTEGER NOT NULL, + local_id TEXT, + invoked_at INTEGER, + scheduled_at INTEGER, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq); + CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_messages_session_position + ON messages(session_id, COALESCE(invoked_at, created_at) DESC, seq DESC); + CREATE INDEX IF NOT EXISTS idx_messages_scheduled_pending + ON messages(scheduled_at) + WHERE scheduled_at IS NOT NULL AND invoked_at IS NULL; + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_user_id TEXT NOT NULL, + namespace TEXT NOT NULL DEFAULT 'default', + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform); + CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + namespace TEXT NOT NULL, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(namespace, endpoint) + ); + CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); + `) +} diff --git a/hub/src/store/scratchlist.ts b/hub/src/store/scratchlist.ts new file mode 100644 index 0000000000..e8305ce579 --- /dev/null +++ b/hub/src/store/scratchlist.ts @@ -0,0 +1,181 @@ +import type { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' + +import type { StoredScratchlistEntry } from './types' + +/** + * Per-session scratchlist storage (tiann/hapi#893, scratchlist v2). + * + * The hub is the source of truth for scratchlist entries; web treats + * `localStorage` as an offline cache only. All queries are scoped by + * `session_id` + (where it matters) the session's namespace - the latter + * is enforced one layer up in `SyncEngine` / web routes via + * `requireSessionFromParam`, so the SQL layer here treats `session_id` + * as the primary scope. + * + * Mental model carried from v1 (#798): scratchlist != queue. Entries are + * notes / drafts / parking-lot ideas, never auto-sent. The hub-side + * representation is deliberately lean: + * + * - `text` plain string (no markdown rendering planned for v2) + * - `created_at` immutable since insert + * - `updated_at` bumped on edits to drive the SSE patch token + * - cascade-delete from `sessions(id)` covers the delete-session path + * + * Per-session caps live in `@hapi/protocol/apiTypes` + * (`SCRATCHLIST_MAX_ENTRIES`, `SCRATCHLIST_MAX_TEXT_LENGTH`); the route + * layer enforces them at write time. The SQL layer accepts whatever it's + * given - the cap is policy, not schema. + */ + +type DbScratchlistRow = { + session_id: string + entry_id: string + text: string + created_at: number + updated_at: number +} + +function toStoredEntry(row: DbScratchlistRow): StoredScratchlistEntry { + return { + sessionId: row.session_id, + entryId: row.entry_id, + text: row.text, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +export function listScratchlistEntries( + db: Database, + sessionId: string +): StoredScratchlistEntry[] { + const rows = db.prepare( + `SELECT session_id, entry_id, text, created_at, updated_at + FROM session_scratchlist + WHERE session_id = ? + ORDER BY created_at DESC, entry_id DESC` + ).all(sessionId) as DbScratchlistRow[] + return rows.map(toStoredEntry) +} + +export function countScratchlistEntries(db: Database, sessionId: string): number { + const row = db.prepare( + 'SELECT COUNT(*) AS n FROM session_scratchlist WHERE session_id = ?' + ).get(sessionId) as { n: number } | undefined + return row?.n ?? 0 +} + +export function getScratchlistEntry( + db: Database, + sessionId: string, + entryId: string +): StoredScratchlistEntry | null { + const row = db.prepare( + `SELECT session_id, entry_id, text, created_at, updated_at + FROM session_scratchlist + WHERE session_id = ? AND entry_id = ?` + ).get(sessionId, entryId) as DbScratchlistRow | undefined + return row ? toStoredEntry(row) : null +} + +/** + * Insert a new scratchlist entry. Returns the stored row on success, or + * `{ outcome: 'duplicate' }` when the supplied `entryId` already exists + * (the migration path can collide on retry; clients should treat that as + * idempotent). `{ outcome: 'session-not-found' }` is returned when the FK + * to `sessions` would fail - keeps the route handler from having to + * pre-check session existence. + */ +export type CreateScratchlistResult = + | { outcome: 'created'; entry: StoredScratchlistEntry } + | { outcome: 'duplicate'; entry: StoredScratchlistEntry } + | { outcome: 'session-not-found' } + +export function createScratchlistEntry( + db: Database, + sessionId: string, + text: string, + options?: { entryId?: string; createdAt?: number } +): CreateScratchlistResult { + const now = Date.now() + const entryId = options?.entryId ?? randomUUID() + const createdAt = options?.createdAt ?? now + const updatedAt = now + + // Pre-check FK so the route layer can return a clean 404. Doing this + // before the INSERT keeps the error-handling path narrower (no + // SQLite-error string parsing). + const sessionExists = db.prepare( + 'SELECT 1 FROM sessions WHERE id = ? LIMIT 1' + ).get(sessionId) as { 1: number } | undefined + if (!sessionExists) { + return { outcome: 'session-not-found' } + } + + const existing = getScratchlistEntry(db, sessionId, entryId) + if (existing) { + return { outcome: 'duplicate', entry: existing } + } + + db.prepare( + `INSERT INTO session_scratchlist + (session_id, entry_id, text, created_at, updated_at) + VALUES (@session_id, @entry_id, @text, @created_at, @updated_at)` + ).run({ + session_id: sessionId, + entry_id: entryId, + text, + created_at: createdAt, + updated_at: updatedAt + }) + + const created = getScratchlistEntry(db, sessionId, entryId) + if (!created) { + // Should be unreachable: we just inserted under the same scope. + throw new Error('Failed to read scratchlist entry after insert') + } + return { outcome: 'created', entry: created } +} + +/** + * Update an existing entry's `text`. Bumps `updated_at` to `Date.now()`. + * Returns `null` when the entry does not exist (route layer turns into a + * 404). Note: `created_at` is intentionally NOT updated. + */ +export function updateScratchlistEntry( + db: Database, + sessionId: string, + entryId: string, + text: string +): StoredScratchlistEntry | null { + const now = Date.now() + const result = db.prepare( + `UPDATE session_scratchlist + SET text = @text, + updated_at = @updated_at + WHERE session_id = @session_id + AND entry_id = @entry_id` + ).run({ + session_id: sessionId, + entry_id: entryId, + text, + updated_at: now + }) + if (result.changes === 0) { + return null + } + return getScratchlistEntry(db, sessionId, entryId) +} + +export function deleteScratchlistEntry( + db: Database, + sessionId: string, + entryId: string +): boolean { + const result = db.prepare( + `DELETE FROM session_scratchlist + WHERE session_id = ? AND entry_id = ?` + ).run(sessionId, entryId) + return result.changes > 0 +} diff --git a/hub/src/store/scratchlistStore.ts b/hub/src/store/scratchlistStore.ts new file mode 100644 index 0000000000..fd285ca6d0 --- /dev/null +++ b/hub/src/store/scratchlistStore.ts @@ -0,0 +1,52 @@ +import type { Database } from 'bun:sqlite' + +import type { StoredScratchlistEntry } from './types' +import { + countScratchlistEntries, + createScratchlistEntry, + deleteScratchlistEntry, + getScratchlistEntry, + listScratchlistEntries, + updateScratchlistEntry, + type CreateScratchlistResult +} from './scratchlist' + +export class ScratchlistStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + list(sessionId: string): StoredScratchlistEntry[] { + return listScratchlistEntries(this.db, sessionId) + } + + count(sessionId: string): number { + return countScratchlistEntries(this.db, sessionId) + } + + get(sessionId: string, entryId: string): StoredScratchlistEntry | null { + return getScratchlistEntry(this.db, sessionId, entryId) + } + + create( + sessionId: string, + text: string, + options?: { entryId?: string; createdAt?: number } + ): CreateScratchlistResult { + return createScratchlistEntry(this.db, sessionId, text, options) + } + + update( + sessionId: string, + entryId: string, + text: string + ): StoredScratchlistEntry | null { + return updateScratchlistEntry(this.db, sessionId, entryId, text) + } + + delete(sessionId: string, entryId: string): boolean { + return deleteScratchlistEntry(this.db, sessionId, entryId) + } +} diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index fb77d85770..a9d6f414cf 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -63,6 +63,14 @@ export type StoredPushSubscription = { createdAt: number } +export type StoredScratchlistEntry = { + sessionId: string + entryId: string + text: string + createdAt: number + updatedAt: number +} + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index ecfabd7e3f..c4d0c6b1ac 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -356,6 +356,35 @@ export class SessionCache { }) } + /** + * tiann/hapi#893 (scratchlist v2): emit a `session-updated` SSE patch + * carrying `scratchlistUpdatedAt` so other clients viewing the same + * session refetch the entries query. Called by `SyncEngine` after + * any successful scratchlist mutation. The timestamp is the trigger, + * not the payload - clients use it as a change-detection token and + * pull entries via the dedicated REST query. + * + * Per operator decision (see brief): piggyback on `session-updated` + * rather than introduce a new event type, because scratchlist + * mutations are exceedingly rare relative to keep-alive patches. + * + * Resolves the namespace from the in-memory session map (or the DB + * row as a fallback) so the SSE manager can scope the broadcast + * correctly even if the cache is cold. + */ + emitScratchlistChanged(sessionId: string, updatedAt: number = Date.now()): void { + const cached = this.sessions.get(sessionId) + const namespace = cached?.namespace + ?? this.store.sessions.getSession(sessionId)?.namespace + if (!namespace) return + this.publisher.emit({ + type: 'session-updated', + sessionId, + namespace, + data: { scratchlistUpdatedAt: updatedAt } satisfies SessionPatch + }) + } + handleSessionEnd(payload: { sid: string; time: number }): void { const t = clampAliveTime(payload.time) ?? Date.now() diff --git a/hub/src/sync/syncEngine-scratchlist.test.ts b/hub/src/sync/syncEngine-scratchlist.test.ts new file mode 100644 index 0000000000..6559cc34e2 --- /dev/null +++ b/hub/src/sync/syncEngine-scratchlist.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'bun:test' +import type { SyncEvent } from '@hapi/protocol/types' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import type { EventPublisher } from './eventPublisher' +import { SessionCache } from './sessionCache' +import { SyncEngine } from './syncEngine' + +/** + * Tests for scratchlist v2 (tiann/hapi#893) wiring at the SyncEngine / + * SessionCache layer: + * - every successful mutation emits a `session-updated` SyncEvent + * carrying `scratchlistUpdatedAt` + * - failed mutations (entry not found, duplicate) emit nothing + * - the patch is namespace-scoped to the session's own namespace so + * the SSE manager doesn't broadcast across operators + * + * The web client uses the patch as a refetch trigger; the timestamp + * itself is the only signal, the entries arrive via the dedicated + * `/api/sessions/:id/scratchlist` GET endpoint. + */ + +function createCapturingPublisher(events: SyncEvent[]): EventPublisher { + return { + emit: (event: SyncEvent) => { + events.push(event) + } + } as unknown as EventPublisher +} + +describe('SessionCache.emitScratchlistChanged', () => { + it('emits a session-updated patch carrying scratchlistUpdatedAt', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + const session = cache.getOrCreateSession( + 'tag', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + // Drain spawn events so we can assert on the scratchlist + // emission alone. + events.length = 0 + + cache.emitScratchlistChanged(session.id, 9999) + + expect(events).toHaveLength(1) + const event = events[0]! + expect(event.type).toBe('session-updated') + if (event.type !== 'session-updated') throw new Error('unreachable') + expect(event.sessionId).toBe(session.id) + expect(event.namespace).toBe('default') + expect(event.data).toEqual({ scratchlistUpdatedAt: 9999 }) + }) + + it('does not emit when the session is unknown (no namespace to scope to)', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + cache.emitScratchlistChanged('does-not-exist', 9999) + expect(events).toHaveLength(0) + }) +}) + +describe('SyncEngine scratchlist mutations emit session-updated patches', () => { + function setup() { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + // We attach the EventPublisher to SyncEngine via a private field + // path so the route-layer surface (createScratchlistEntry, etc.) + // exercises the same code path used in production. We only need + // the cache for `getOrCreateSession`; the engine reuses the + // store internally. + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + // SyncEngine constructs its own SessionCache internally - shimming + // the inner one would be brittle. Use the engine's events stream + // directly via subscription. + const engineEvents: SyncEvent[] = [] + engine.subscribe((e) => { engineEvents.push(e) }) + return { engine, store, events, cache, engineEvents } + } + + it('createScratchlistEntry emits a session-updated patch on success', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-create', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + // Drain events from the spawn so we can assert on the mutation + // emission alone. + engineEvents.length = 0 + + const result = engine.createScratchlistEntry(session.id, 'note', { entryId: 'e1' }) + expect(result.outcome).toBe('created') + + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(1) + const patch = matching[0] + if (!patch || patch.type !== 'session-updated') throw new Error('unreachable') + expect(patch.sessionId).toBe(session.id) + expect(patch.namespace).toBe('default') + + engine.stop() + }) + + it('updateScratchlistEntry emits a session-updated patch on success', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-update', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engine.createScratchlistEntry(session.id, 'before', { entryId: 'e1' }) + engineEvents.length = 0 + + const updated = engine.updateScratchlistEntry(session.id, 'e1', 'after') + expect(updated).not.toBeNull() + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(1) + + engine.stop() + }) + + it('updateScratchlistEntry on a missing entry emits nothing', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-update-missing', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engineEvents.length = 0 + const updated = engine.updateScratchlistEntry(session.id, 'never-existed', 'whatever') + expect(updated).toBeNull() + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(0) + engine.stop() + }) + + it('deleteScratchlistEntry emits a session-updated patch on success', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-delete', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engine.createScratchlistEntry(session.id, 'doomed', { entryId: 'e1' }) + engineEvents.length = 0 + const removed = engine.deleteScratchlistEntry(session.id, 'e1') + expect(removed).toBe(true) + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(1) + engine.stop() + }) + + it('deleteScratchlistEntry on a missing entry emits nothing', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-delete-missing', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engineEvents.length = 0 + const removed = engine.deleteScratchlistEntry(session.id, 'no-such-entry') + expect(removed).toBe(false) + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(0) + engine.stop() + }) + + it('createScratchlistEntry on duplicate does not emit an extra patch', () => { + const { engine, engineEvents } = setup() + const session = engine.getOrCreateSession( + 'tag-dup', + { path: '/tmp', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + engine.createScratchlistEntry(session.id, 'first', { entryId: 'dup' }) + engineEvents.length = 0 + const result = engine.createScratchlistEntry(session.id, 'second', { entryId: 'dup' }) + if (result.outcome === 'session-not-found') throw new Error('unexpected') + expect(result.outcome).toBe('duplicate') + const matching = engineEvents.filter( + (e) => e.type === 'session-updated' && (e.data as Record).scratchlistUpdatedAt !== undefined + ) + expect(matching).toHaveLength(0) + engine.stop() + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a7f88c032a..739d415e44 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -329,6 +329,92 @@ export class SyncEngine { this.sessionCache.recordSessionActivity(sessionId, updatedAt) } + /** + * tiann/hapi#893 (scratchlist v2). Read-side: list entries for a + * session. Auth / namespace check is the route layer's job (via + * `requireSessionFromParam`); by the time we get here the caller + * already proved access. + */ + listScratchlistEntries(sessionId: string): Array<{ + entryId: string + text: string + createdAt: number + updatedAt: number + }> { + return this.store.scratchlist.list(sessionId).map((row) => ({ + entryId: row.entryId, + text: row.text, + createdAt: row.createdAt, + updatedAt: row.updatedAt + })) + } + + countScratchlistEntries(sessionId: string): number { + return this.store.scratchlist.count(sessionId) + } + + /** + * Insert a scratchlist entry. Returns the canonical row on success + * (so the route layer can serialise it without a follow-up read). + * Emits a `session-updated` SSE patch carrying `scratchlistUpdatedAt` + * so other clients viewing the same session refetch. + * + * `outcome: 'duplicate'` covers the migration path's idempotency: + * the web client may retry pushing a localStorage entry after a + * partial failure; the second attempt should be a no-op rather than + * a hard error. Route layer maps duplicate → 200/conflict per its + * own contract; this layer just reports it. + */ + createScratchlistEntry( + sessionId: string, + text: string, + options?: { entryId?: string; createdAt?: number } + ): { + outcome: 'created' | 'duplicate' + entry: { entryId: string; text: string; createdAt: number; updatedAt: number } + } | { outcome: 'session-not-found' } { + const result = this.store.scratchlist.create(sessionId, text, options) + if (result.outcome === 'session-not-found') { + return result + } + if (result.outcome === 'created') { + this.sessionCache.emitScratchlistChanged(sessionId, result.entry.updatedAt) + } + return { + outcome: result.outcome, + entry: { + entryId: result.entry.entryId, + text: result.entry.text, + createdAt: result.entry.createdAt, + updatedAt: result.entry.updatedAt + } + } + } + + updateScratchlistEntry( + sessionId: string, + entryId: string, + text: string + ): { entryId: string; text: string; createdAt: number; updatedAt: number } | null { + const updated = this.store.scratchlist.update(sessionId, entryId, text) + if (!updated) return null + this.sessionCache.emitScratchlistChanged(sessionId, updated.updatedAt) + return { + entryId: updated.entryId, + text: updated.text, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt + } + } + + deleteScratchlistEntry(sessionId: string, entryId: string): boolean { + const removed = this.store.scratchlist.delete(sessionId, entryId) + if (removed) { + this.sessionCache.emitScratchlistChanged(sessionId, Date.now()) + } + return removed + } + handleMachineAlive(payload: { machineId: string; time: number }): void { this.machineCache.handleMachineAlive(payload) } diff --git a/hub/src/web/routes/sessions-scratchlist.test.ts b/hub/src/web/routes/sessions-scratchlist.test.ts new file mode 100644 index 0000000000..33e2b7717a --- /dev/null +++ b/hub/src/web/routes/sessions-scratchlist.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createSessionsRoutes } from './sessions' + +/** + * Tests for the scratchlist v2 (tiann/hapi#893) REST routes: + * GET /api/sessions/:id/scratchlist + * POST /api/sessions/:id/scratchlist + * PUT /api/sessions/:id/scratchlist/:entryId + * DELETE /api/sessions/:id/scratchlist/:entryId + * + * The routes call into a small surface on `SyncEngine` (list/create/ + * update/delete + count). We mock that surface here so the assertions + * focus on: + * - happy-path response shapes + * - auth + namespace gating via `requireSessionFromParam` + * - validation (text required, max length) + * - cap enforcement at SCRATCHLIST_MAX_ENTRIES + * - 404 paths (missing session, missing entry) + * - 200 vs 201 split (created vs duplicate during migration retries) + * + * SSE emission is exercised at the SyncEngine + SessionCache layer in a + * separate test (`syncEngine-scratchlist.test.ts`). + */ + +function createSession(overrides?: Partial): Session { + const baseMetadata = { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex' as const + } + const base: Session = { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: baseMetadata, + metadataVersion: 1, + agentState: { + controlledByUser: false, + requests: {}, + completedRequests: {} + }, + agentStateVersion: 1, + thinking: false, + thinkingAt: 1, + model: 'gpt-5.4', + modelReasoningEffort: null, + effort: null, + permissionMode: 'default', + collaborationMode: 'default' + } + return { ...base, ...overrides } +} + +type EngineOverrides = Partial<{ + listScratchlistEntries: SyncEngine['listScratchlistEntries'] + countScratchlistEntries: SyncEngine['countScratchlistEntries'] + createScratchlistEntry: SyncEngine['createScratchlistEntry'] + updateScratchlistEntry: SyncEngine['updateScratchlistEntry'] + deleteScratchlistEntry: SyncEngine['deleteScratchlistEntry'] + sessionAccess: 'ok' | 'not-found' | 'wrong-namespace' + callerNamespace: string +}> + +function createApp(session: Session, overrides: EngineOverrides = {}) { + const engine = { + resolveSessionAccess: () => { + if (overrides.sessionAccess === 'not-found') { + return { ok: false, reason: 'not-found' as const } + } + if (overrides.sessionAccess === 'wrong-namespace') { + return { ok: false, reason: 'access-denied' as const } + } + return { ok: true, sessionId: session.id, session } + }, + listScratchlistEntries: overrides.listScratchlistEntries ?? (() => []), + countScratchlistEntries: overrides.countScratchlistEntries ?? (() => 0), + createScratchlistEntry: overrides.createScratchlistEntry + ?? ((sessionId: string, text: string) => ({ + outcome: 'created' as const, + entry: { + entryId: `auto-${Date.now()}`, + text, + createdAt: 1000, + updatedAt: 1000 + } + })), + updateScratchlistEntry: overrides.updateScratchlistEntry + ?? ((sessionId: string, entryId: string, text: string) => ({ + entryId, + text, + createdAt: 1000, + updatedAt: 2000 + })), + deleteScratchlistEntry: overrides.deleteScratchlistEntry ?? (() => true) + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', overrides.callerNamespace ?? 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + return app +} + +describe('GET /api/sessions/:id/scratchlist', () => { + it('returns the entries returned by the engine', async () => { + const session = createSession() + const app = createApp(session, { + listScratchlistEntries: () => [ + { entryId: 'a', text: 'note A', createdAt: 1000, updatedAt: 1000 }, + { entryId: 'b', text: 'note B', createdAt: 2000, updatedAt: 2500 } + ] + }) + const res = await app.request('/api/sessions/session-1/scratchlist') + expect(res.status).toBe(200) + const body = await res.json() as { entries: Array<{ entryId: string }> } + expect(body.entries.map((e) => e.entryId)).toEqual(['a', 'b']) + }) + + it('returns 404 when the session is not visible to the caller', async () => { + const session = createSession() + const app = createApp(session, { sessionAccess: 'not-found' }) + const res = await app.request('/api/sessions/session-1/scratchlist') + expect(res.status).toBe(404) + }) + + it('returns 403 when the session belongs to a different namespace', async () => { + const session = createSession({ namespace: 'other' }) + const app = createApp(session, { sessionAccess: 'wrong-namespace' }) + const res = await app.request('/api/sessions/session-1/scratchlist') + expect(res.status).toBe(403) + }) +}) + +describe('POST /api/sessions/:id/scratchlist', () => { + it('creates an entry and returns 201 with the canonical row', async () => { + const session = createSession() + const calls: Array<{ sessionId: string; text: string; entryId?: string; createdAt?: number }> = [] + const app = createApp(session, { + createScratchlistEntry: (sessionId, text, options) => { + calls.push({ sessionId, text, entryId: options?.entryId, createdAt: options?.createdAt }) + return { + outcome: 'created' as const, + entry: { + entryId: options?.entryId ?? 'fresh-id', + text, + createdAt: options?.createdAt ?? 1000, + updatedAt: 1000 + } + } + } + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'first thought' }) + }) + expect(res.status).toBe(201) + const body = await res.json() as { entry: { text: string; entryId: string } } + expect(body.entry.text).toBe('first thought') + expect(calls).toHaveLength(1) + expect(calls[0]?.sessionId).toBe('session-1') + }) + + it('returns 200 with the existing row on duplicate (migration idempotency path)', async () => { + const session = createSession() + const app = createApp(session, { + createScratchlistEntry: () => ({ + outcome: 'duplicate' as const, + entry: { entryId: 'dup', text: 'pre-existing', createdAt: 100, updatedAt: 100 } + }) + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'replay', entryId: 'dup' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { entry: { text: string } } + expect(body.entry.text).toBe('pre-existing') + }) + + it('rejects empty text with 400', async () => { + const session = createSession() + const app = createApp(session) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: '' }) + }) + expect(res.status).toBe(400) + }) + + it('rejects oversize text (>10_000 chars) with 400', async () => { + const session = createSession() + const app = createApp(session) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'x'.repeat(10_001) }) + }) + expect(res.status).toBe(400) + }) + + it('returns 409 when the session is at the cap', async () => { + const session = createSession() + const app = createApp(session, { + countScratchlistEntries: () => 200 + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'one too many' }) + }) + expect(res.status).toBe(409) + const body = await res.json() as { code: string } + expect(body.code).toBe('scratchlist_at_cap') + }) + + it('returns 404 when the engine reports session-not-found post-auth', async () => { + // This path covers a race: auth said the session was visible + // (resolveSessionAccess.ok), but by the time we INSERT the row the + // session is gone. The engine returns `session-not-found` and the + // route surfaces a 404. + const session = createSession() + const app = createApp(session, { + createScratchlistEntry: () => ({ outcome: 'session-not-found' as const }) + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'never lands' }) + }) + expect(res.status).toBe(404) + }) + + it('returns 404 when the session is not visible to the caller', async () => { + const session = createSession() + const app = createApp(session, { sessionAccess: 'not-found' }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'auth gate' }) + }) + expect(res.status).toBe(404) + }) +}) + +describe('PUT /api/sessions/:id/scratchlist/:entryId', () => { + it('returns the updated entry on success', async () => { + const session = createSession() + const app = createApp(session, { + updateScratchlistEntry: (_sessionId, entryId, text) => ({ + entryId, + text, + createdAt: 1000, + updatedAt: 5000 + }) + }) + const res = await app.request('/api/sessions/session-1/scratchlist/entry-1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'edited' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { entry: { text: string; entryId: string } } + expect(body.entry.text).toBe('edited') + expect(body.entry.entryId).toBe('entry-1') + }) + + it('returns 404 when the entry does not exist', async () => { + const session = createSession() + const app = createApp(session, { + updateScratchlistEntry: () => null + }) + const res = await app.request('/api/sessions/session-1/scratchlist/missing-id', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'oops' }) + }) + expect(res.status).toBe(404) + }) + + it('rejects empty text with 400', async () => { + const session = createSession() + const app = createApp(session) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: '' }) + }) + expect(res.status).toBe(400) + }) + + it('returns 403 when the session is in another namespace', async () => { + const session = createSession({ namespace: 'other' }) + const app = createApp(session, { sessionAccess: 'wrong-namespace' }) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'cross-ns' }) + }) + expect(res.status).toBe(403) + }) +}) + +describe('DELETE /api/sessions/:id/scratchlist/:entryId', () => { + it('returns ok:true when the row was removed', async () => { + const session = createSession() + const app = createApp(session, { + deleteScratchlistEntry: () => true + }) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'DELETE' + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + }) + + it('returns 404 when the row did not exist', async () => { + const session = createSession() + const app = createApp(session, { + deleteScratchlistEntry: () => false + }) + const res = await app.request('/api/sessions/session-1/scratchlist/missing', { + method: 'DELETE' + }) + expect(res.status).toBe(404) + }) + + it('returns 404 when the session is not visible to the caller', async () => { + const session = createSession() + const app = createApp(session, { sessionAccess: 'not-found' }) + const res = await app.request('/api/sessions/session-1/scratchlist/e1', { + method: 'DELETE' + }) + expect(res.status).toBe(404) + }) +}) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 3725a3bd10..977073fcac 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -5,6 +5,9 @@ import { isPermissionModeAllowedForFlavor, RenameSessionRequestSchema, ResumeSessionRequestSchema, + SCRATCHLIST_MAX_ENTRIES, + ScratchlistEntryCreateRequestSchema, + ScratchlistEntryUpdateRequestSchema, SessionCollaborationModeRequestSchema, SessionEffortRequestSchema, SessionModelReasoningEffortRequestSchema, @@ -606,6 +609,131 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + /* + * Scratchlist v2 (tiann/hapi#893). + * + * Operator-private notes attached to a session. All four routes use + * the existing `requireSessionFromParam` guard so the same auth / + * namespace check applies as every other session-scoped route - + * scratchlist contents must NOT leak across namespaces, and a 403 / + * 404 is returned for sessions the caller cannot access. + * + * SSE: every successful mutation emits a `session-updated` patch + * carrying `scratchlistUpdatedAt` (handled in `SyncEngine`). The web + * client uses that as a cache-invalidation token to refetch GET. + */ + + app.get('/sessions/:id/scratchlist', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const entries = engine.listScratchlistEntries(sessionResult.sessionId) + return c.json({ entries }) + }) + + app.post('/sessions/:id/scratchlist', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const body = await c.req.json().catch(() => null) + const parsed = ScratchlistEntryCreateRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + + // Server-side cap enforcement. Mirrors the web-side cap so a + // malicious / runaway client can't drive the table without + // bound. Bypassing the optimistic add path on the web client + // (e.g. direct REST call) hits this guard. Bumped only with the + // shared SCRATCHLIST_MAX_ENTRIES constant. + const currentCount = engine.countScratchlistEntries(sessionResult.sessionId) + if (currentCount >= SCRATCHLIST_MAX_ENTRIES) { + return c.json({ + error: `Scratchlist is at its ${SCRATCHLIST_MAX_ENTRIES}-entry cap`, + code: 'scratchlist_at_cap' + }, 409) + } + + const result = engine.createScratchlistEntry( + sessionResult.sessionId, + parsed.data.text, + { + entryId: parsed.data.entryId, + createdAt: parsed.data.createdAt + } + ) + if (result.outcome === 'session-not-found') { + return c.json({ error: 'Session not found' }, 404) + } + // `duplicate` (same entryId already exists) returns 200 with the + // canonical row so the migration path can retry idempotently. + // The web client treats 200-with-existing as success either way. + return c.json({ entry: result.entry }, result.outcome === 'created' ? 201 : 200) + }) + + app.put('/sessions/:id/scratchlist/:entryId', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + const entryId = c.req.param('entryId') + if (!entryId) { + return c.json({ error: 'Missing entryId' }, 400) + } + + const body = await c.req.json().catch(() => null) + const parsed = ScratchlistEntryUpdateRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + + const updated = engine.updateScratchlistEntry( + sessionResult.sessionId, + entryId, + parsed.data.text + ) + if (!updated) { + return c.json({ error: 'Scratchlist entry not found' }, 404) + } + return c.json({ entry: updated }) + }) + + app.delete('/sessions/:id/scratchlist/:entryId', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const entryId = c.req.param('entryId') + if (!entryId) { + return c.json({ error: 'Missing entryId' }, 400) + } + const removed = engine.deleteScratchlistEntry(sessionResult.sessionId, entryId) + if (!removed) { + return c.json({ error: 'Scratchlist entry not found' }, 404) + } + return c.json({ ok: true }) + }) + app.get('/sessions/:id/slash-commands', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 604e1a901f..c18c15ce10 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -147,6 +147,50 @@ export const RenameSessionRequestSchema = z.object({ export type RenameSessionRequest = z.infer +/** + * Scratchlist v2 (tiann/hapi#893) per-entry caps. + * + * `MAX_ENTRIES` (200) is a per-session ceiling: refuses to create entry + * 201 on the hub. Mirrors `SCRATCHLIST_MAX_ENTRIES` in + * `web/src/lib/scratchlist.ts` so the hub and web agree on the limit - + * the web side has UX for the cap (disabled add button + atCap hint), + * the hub side enforces it as a server-side guard against malicious / + * runaway clients writing arbitrary amounts. + * + * `MAX_TEXT_LENGTH` (10_000) is the per-entry text cap. Mirrors + * `SCRATCHLIST_MAX_TEXT_LENGTH`. The web side truncates rather than + * rejects; the hub-side schema allows up to this length and rejects + * anything longer with 400. + */ +export const SCRATCHLIST_MAX_ENTRIES = 200 +export const SCRATCHLIST_MAX_TEXT_LENGTH = 10_000 + +export const ScratchlistEntryCreateRequestSchema = z.object({ + /** + * Optional client-supplied entry id. Lets the web client preserve its + * pre-v2 localStorage entry ids during migration so the optimistic- + * update path doesn't have to re-key entries already in the React + * tree. New entries created post-v2 can omit this and let the hub + * generate one. + */ + entryId: z.string().min(1).optional(), + text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH), + /** + * Optional client-supplied createdAt. Used by the migration path to + * preserve the original timestamps from localStorage. New entries + * omit this and let the hub stamp `Date.now()`. + */ + createdAt: z.number().int().nonnegative().optional() +}) + +export type ScratchlistEntryCreateRequest = z.infer + +export const ScratchlistEntryUpdateRequestSchema = z.object({ + text: z.string().min(1).max(SCRATCHLIST_MAX_TEXT_LENGTH) +}) + +export type ScratchlistEntryUpdateRequest = z.infer + /** Per-session legacy stream-json → ACP migrator request. See tiann/hapi#824. */ export const CursorMigrateToAcpRequestSchema = z.object({ /** Skip removing the legacy ~/.cursor/chats source store.db even after verify passes. */ diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 22a6fd511a..868bdf2845 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -230,11 +230,38 @@ export const SessionPatchSchema = z.object({ effort: z.string().nullable().optional(), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional(), - backgroundTaskCount: z.number().optional() + backgroundTaskCount: z.number().optional(), + // tiann/hapi#893 (scratchlist v2). Bumped whenever any entry on the + // session_scratchlist table mutates. Web client uses the change as a + // trigger to refetch the entries query - the timestamp itself is the + // signal, not the payload. Keep this minimal: per the operator's 80/20 + // ruling, scratchlist mutations are rare relative to keep-alive + // patches, so a fresh event type would be overkill. + scratchlistUpdatedAt: z.number().optional() }).strict() export type SessionPatch = z.infer +// tiann/hapi#893: per-session scratchlist entries (operator notes / +// drafts / parking-lot ideas). Hub-side typed-table source of truth; +// web treats localStorage as offline cache only. Single-user notes - +// no collaborative edit semantics (no version field, no conflict +// resolution beyond last-write-wins). +export const ScratchlistEntrySchema = z.object({ + entryId: z.string().min(1), + text: z.string(), + createdAt: z.number(), + updatedAt: z.number() +}) + +export type ScratchlistEntry = z.infer + +export const ScratchlistEntriesResponseSchema = z.object({ + entries: z.array(ScratchlistEntrySchema) +}) + +export type ScratchlistEntriesResponse = z.infer + export const MachineMetadataSchema = z.object({ host: z.string(), platform: z.string(), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 488e7ea24e..f187f670cc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -664,6 +664,60 @@ export class ApiClient { }) } + /* + * Scratchlist v2 (tiann/hapi#893). + * + * The hub is the durable store; localStorage is demoted to an + * offline cache. Mutations return the canonical entry so optimistic + * updates can reconcile with the hub-stamped `updatedAt`. + */ + + async getScratchlist(sessionId: string): Promise<{ + entries: Array<{ entryId: string; text: string; createdAt: number; updatedAt: number }> + }> { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist` + ) + } + + async createScratchlistEntry( + sessionId: string, + body: { text: string; entryId?: string; createdAt?: number } + ): Promise<{ + entry: { entryId: string; text: string; createdAt: number; updatedAt: number } + }> { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist`, + { + method: 'POST', + body: JSON.stringify(body) + } + ) + } + + async updateScratchlistEntry( + sessionId: string, + entryId: string, + text: string + ): Promise<{ + entry: { entryId: string; text: string; createdAt: number; updatedAt: number } + }> { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`, + { + method: 'PUT', + body: JSON.stringify({ text }) + } + ) + } + + async deleteScratchlistEntry(sessionId: string, entryId: string): Promise { + await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/scratchlist/${encodeURIComponent(entryId)}`, + { method: 'DELETE' } + ) + } + async fetchVoiceToken(options?: { customAgentId?: string; customApiKey?: string; voiceId?: string }): Promise<{ allowed: boolean token?: string diff --git a/web/src/components/AssistantChat/ScratchlistMigrationBanner.test.tsx b/web/src/components/AssistantChat/ScratchlistMigrationBanner.test.tsx new file mode 100644 index 0000000000..d1b8be7556 --- /dev/null +++ b/web/src/components/AssistantChat/ScratchlistMigrationBanner.test.tsx @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { ScratchlistMigrationBanner } from './ScratchlistMigrationBanner' + +afterEach(() => cleanup()) + +function renderBanner(props: { + migrationStatus: 'idle' | 'migrating' | 'completed' | 'dismissed' | 'pre-migrated' + onDismiss?: () => void +}) { + return render( + + + + ) +} + +describe('ScratchlistMigrationBanner', () => { + it('renders nothing in idle state', () => { + const { container } = renderBanner({ migrationStatus: 'idle' }) + expect(container.firstChild).toBeNull() + }) + + it('renders nothing while the migration is in flight', () => { + const { container } = renderBanner({ migrationStatus: 'migrating' }) + expect(container.firstChild).toBeNull() + }) + + it('renders nothing for the dismissed state', () => { + const { container } = renderBanner({ migrationStatus: 'dismissed' }) + expect(container.firstChild).toBeNull() + }) + + it('renders nothing for pre-migrated sessions (operator already saw the banner once)', () => { + const { container } = renderBanner({ migrationStatus: 'pre-migrated' }) + expect(container.firstChild).toBeNull() + }) + + it('renders the banner with title, body, and dismiss button when status is completed', () => { + renderBanner({ migrationStatus: 'completed' }) + expect(screen.getByTestId('scratchlist-migration-banner')).toBeTruthy() + // Title contains the cross-device cue. + expect(screen.getByText(/syncs across devices/i)).toBeTruthy() + // Body contains the "nothing was lost" reassurance. + expect(screen.getByText(/nothing was lost/i)).toBeTruthy() + // Dismiss button exists. + expect(screen.getByTestId('scratchlist-migration-banner-dismiss')).toBeTruthy() + }) + + it('calls onDismiss when the dismiss button is clicked', () => { + const onDismiss = vi.fn() + renderBanner({ migrationStatus: 'completed', onDismiss }) + fireEvent.click(screen.getByTestId('scratchlist-migration-banner-dismiss')) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/components/AssistantChat/ScratchlistMigrationBanner.tsx b/web/src/components/AssistantChat/ScratchlistMigrationBanner.tsx new file mode 100644 index 0000000000..eb6520afcb --- /dev/null +++ b/web/src/components/AssistantChat/ScratchlistMigrationBanner.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from '@/lib/use-translation' + +/** + * tiann/hapi#893 (scratchlist v2): one-time banner shown the first time + * a v2-aware client encounters localStorage entries on a session and + * pushes them up to the hub silently. + * + * Visibility contract: + * - Renders only when `migrationStatus === 'completed'` (the hook's + * signal that the migration just ran in this session). + * - Operator-affirmative dismissal: clicking the dismiss button writes + * the per-session `hapi.scratchlist.v2.banner-dismissed.${id}` flag + * so the banner does not reappear on reload. + * - Mirrors the dismissal pattern of `CursorMigrationBanner.tsx` so + * the surface is familiar to operators. + * + * Copy explains what was migrated and confirms nothing was lost. We + * deliberately do not show entry counts - the banner is informational, + * not transactional, and a count would imply the operator should + * verify, which we don't want them to feel they need to do. + */ +export function ScratchlistMigrationBanner({ + migrationStatus, + onDismiss +}: { + migrationStatus: + | 'idle' + | 'migrating' + | 'completed' + | 'dismissed' + | 'pre-migrated' + onDismiss: () => void +}) { + const { t } = useTranslation() + if (migrationStatus !== 'completed') { + return null + } + return ( +
+
+
+
+ {t('scratchlist.migrationBanner.title')} +
+
+ {t('scratchlist.migrationBanner.body')} +
+
+ +
+
+ ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 8aa2f6ccbc..13baffdc6e 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -25,7 +25,8 @@ import { resolvePendingSchedule } from '@/components/AssistantChat/ScheduleTimeP import { HappyThread } from '@/components/AssistantChat/HappyThread' import { QueuedMessagesBar } from '@/components/AssistantChat/QueuedMessagesBar' import { ScratchlistDrawer } from '@/components/AssistantChat/ScratchlistPanel' -import { useScratchlist } from '@/lib/use-scratchlist' +import { useHubScratchlist } from '@/lib/use-hub-scratchlist' +import { ScratchlistMigrationBanner } from '@/components/AssistantChat/ScratchlistMigrationBanner' import { useHappyRuntime } from '@/lib/assistant-runtime' import { createAttachmentAdapter } from '@/lib/attachmentAdapter' import { useTranslation } from '@/lib/use-translation' @@ -166,9 +167,9 @@ function isUninvokedScheduledMessage(message: DecryptedMessage): boolean { * composer-toolbar counter and the drawer share one source of truth. */ export function ScratchlistDrawerHost(props: { - entries: ReturnType['entries'] - onMove: ReturnType['move'] - onDelete: ReturnType['remove'] + entries: ReturnType['entries'] + onMove: ReturnType['move'] + onDelete: ReturnType['remove'] onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise /** * Called when the operator promotes an entry to the composer. @@ -313,7 +314,7 @@ function SessionChatInner(props: SessionChatProps) { const [outlineOpen, setOutlineOpen] = useState(false) const [cursorSelectedBase, setCursorSelectedBase] = useState('auto') const lastSyncedCursorModelRef = useRef(undefined) - const scratchlist = useScratchlist(props.session.id) + const scratchlist = useHubScratchlist(props.session.id, props.api) const [scratchlistMode, setScratchlistMode] = useState(false) // Mode resets across sessions implicitly: SessionChat is keyed by // session.id at the public-export boundary, so a session switch @@ -1035,6 +1036,19 @@ function SessionChatInner(props: SessionChatProps) { ) : null} + {/* + * tiann/hapi#893: one-time banner shown on first + * v2-load when localStorage entries got migrated to + * the hub. Sits above the drawer so the operator + * sees it whether or not the drawer is open. + * Auto-renders nothing unless `migrationStatus === + * 'completed'`. + */} + +
{/* * Scratchlist drawer - composer-controlled. Only diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index a65f84d0fe..c37499b414 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -7,6 +7,7 @@ import { SessionActionMenu } from '@/components/SessionActionMenu' import { SessionExportDialog } from '@/components/SessionExportDialog' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { useScratchlistCount } from '@/lib/use-scratchlist-count' import { formatReopenError } from '@/lib/reopenError' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' @@ -117,6 +118,11 @@ export function SessionHeader(props: { session.metadata?.flavor ?? null ) const [reopenError, setReopenError] = useState(null) + // tiann/hapi#893: surface the scratchlist entry count in the + // delete-confirm copy so the operator knows what cascades when they + // confirm. Read-only hook reuses the cache filled by SessionChat - + // no extra network when both components are mounted. + const scratchlistCount = useScratchlistCount(session.id, api) const handleDelete = async () => { await deleteSession() @@ -290,7 +296,16 @@ export function SessionHeader(props: { isOpen={deleteOpen} onClose={() => setDeleteOpen(false)} title={t('dialog.delete.title')} - description={t('dialog.delete.description', { name: title })} + description={ + scratchlistCount > 0 + ? `${t('dialog.delete.description', { name: title })} ${t( + scratchlistCount === 1 + ? 'dialog.delete.scratchlist.one' + : 'dialog.delete.scratchlist.other', + { n: String(scratchlistCount) } + )}` + : t('dialog.delete.description', { name: title }) + } confirmLabel={t('dialog.delete.confirm')} confirmingLabel={t('dialog.delete.confirming')} onConfirm={handleDelete} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 11569e43a3..dfda0a1ecc 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -137,7 +137,8 @@ export function useSSE(options: { sessions: boolean machines: boolean sessionIds: Set - }>({ sessions: false, machines: false, sessionIds: new Set() }) + scratchlistSessionIds: Set + }>({ sessions: false, machines: false, sessionIds: new Set(), scratchlistSessionIds: new Set() }) const reconnectTimerRef = useRef | null>(null) const reconnectAttemptRef = useRef(0) const lastActivityAtRef = useRef(0) @@ -182,6 +183,7 @@ export function useSSE(options: { pendingInvalidationsRef.current.sessions = false pendingInvalidationsRef.current.machines = false pendingInvalidationsRef.current.sessionIds.clear() + pendingInvalidationsRef.current.scratchlistSessionIds.clear() if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null @@ -240,17 +242,24 @@ export function useSSE(options: { const flushInvalidations = () => { const pending = pendingInvalidationsRef.current - if (!pending.sessions && !pending.machines && pending.sessionIds.size === 0) { + if ( + !pending.sessions + && !pending.machines + && pending.sessionIds.size === 0 + && pending.scratchlistSessionIds.size === 0 + ) { return } const shouldInvalidateSessions = pending.sessions const shouldInvalidateMachines = pending.machines const sessionIds = Array.from(pending.sessionIds) + const scratchlistSessionIds = Array.from(pending.scratchlistSessionIds) pending.sessions = false pending.machines = false pending.sessionIds.clear() + pending.scratchlistSessionIds.clear() const tasks: Array> = [] if (shouldInvalidateSessions) { @@ -259,6 +268,9 @@ export function useSSE(options: { for (const sessionId of sessionIds) { tasks.push(queryClient.invalidateQueries({ queryKey: queryKeys.session(sessionId) })) } + for (const sessionId of scratchlistSessionIds) { + tasks.push(queryClient.invalidateQueries({ queryKey: queryKeys.scratchlist(sessionId) })) + } if (shouldInvalidateMachines) { tasks.push(queryClient.invalidateQueries({ queryKey: queryKeys.machines })) } @@ -289,6 +301,11 @@ export function useSSE(options: { scheduleInvalidationFlush() } + const queueScratchlistInvalidation = (sessionId: string) => { + pendingInvalidationsRef.current.scratchlistSessionIds.add(sessionId) + scheduleInvalidationFlush() + } + const queueMachinesInvalidation = () => { pendingInvalidationsRef.current.machines = true scheduleInvalidationFlush() @@ -506,6 +523,13 @@ export function useSSE(options: { if (!summaryPatched) { queueSessionListInvalidation() } + // tiann/hapi#893: piggybacked scratchlist token. + // The patch itself does not carry the entries - + // the timestamp is the change-detection signal, + // which triggers the dedicated query refetch. + if (Object.prototype.hasOwnProperty.call(patch, 'scratchlistUpdatedAt')) { + queueScratchlistInvalidation(event.sessionId) + } } else { queueSessionDetailInvalidation(event.sessionId) queueSessionListInvalidation() diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5a0aba4e82..102902811a 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -163,6 +163,8 @@ export default { 'dialog.reopen.dismiss': 'Dismiss', 'dialog.delete.title': 'Delete Session', 'dialog.delete.description': 'Are you sure you want to delete "{name}"? This action cannot be undone.', + 'dialog.delete.scratchlist.one': 'This will also delete 1 scratchlist entry.', + 'dialog.delete.scratchlist.other': 'This will also delete {n} scratchlist entries.', 'dialog.delete.confirm': 'Delete', 'dialog.delete.confirming': 'Deleting…', 'dialog.error.default': 'Operation failed. Please try again.', @@ -432,6 +434,9 @@ export default { 'scratchlist.action.copy': 'Copy to clipboard', 'scratchlist.action.copied': 'Copied!', 'scratchlist.action.delete': 'Delete entry', + 'scratchlist.migrationBanner.title': 'Scratchlist now syncs across devices', + 'scratchlist.migrationBanner.body': 'Your existing notes were copied to the hub - nothing was lost. From now on, edits in this session will appear on every device that you use.', + 'scratchlist.migrationBanner.dismiss': 'Got it', 'fue.newFeatureDot': 'New feature available', 'fue.gotIt': 'Got it', 'fue.closeAriaLabel': 'Close explainer', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d3cecec503..6ec02adea2 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -166,6 +166,8 @@ export default { 'dialog.delete.title': '删除会话', 'dialog.delete.description': '确定要删除 "{name}" 吗?此操作无法撤销。', + 'dialog.delete.scratchlist.one': '这也将删除 1 条草稿清单条目。', + 'dialog.delete.scratchlist.other': '这也将删除 {n} 条草稿清单条目。', 'dialog.delete.confirm': '删除', 'dialog.delete.confirming': '删除中…', @@ -436,6 +438,9 @@ export default { 'scratchlist.action.copy': '复制到剪贴板', 'scratchlist.action.copied': '已复制!', 'scratchlist.action.delete': '删除条目', + 'scratchlist.migrationBanner.title': '草稿清单现已跨设备同步', + 'scratchlist.migrationBanner.body': '您现有的笔记已复制到 hub - 没有丢失任何内容。从现在开始,在此会话中的编辑会在您使用的每台设备上显示。', + 'scratchlist.migrationBanner.dismiss': '知道了', 'fue.newFeatureDot': '新功能可用', 'fue.gotIt': '知道了', 'fue.closeAriaLabel': '关闭说明', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a0664af7e2..4aef42b8f9 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -22,4 +22,5 @@ export const queryKeys = { sessionOpencodeReasoningEffortOptions: (sessionId: string) => ['session-opencode-reasoning-effort-options', sessionId] as const, machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const, skills: (sessionId: string) => ['skills', sessionId] as const, + scratchlist: (sessionId: string) => ['scratchlist', sessionId] as const, } diff --git a/web/src/lib/use-hub-scratchlist.test.tsx b/web/src/lib/use-hub-scratchlist.test.tsx new file mode 100644 index 0000000000..74883ac824 --- /dev/null +++ b/web/src/lib/use-hub-scratchlist.test.tsx @@ -0,0 +1,360 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import type { ApiClient } from '@/api/client' +import { useHubScratchlist } from './use-hub-scratchlist' + +/** + * Tests for the v2 hub-backed scratchlist hook (tiann/hapi#893). + * Covers: + * - initial fetch + * - optimistic add + rollback on error + * - optimistic delete + rollback on error + * - update mutation + * - first-load localStorage → hub migration + banner status flip + * - banner dismissal persistence + * - per-session migration flag prevents re-migration + * - cap enforcement returns false from add() + * - local-only reorder via move() + * + * Per-test session id: each test calls `makeSid()` to get a fresh + * session-scoped localStorage namespace. The hook's offline-cache + * useEffect mirrors entries to `hapi.scratchlist.v1.${sessionId}` and + * the cleanup effect can flush AFTER `afterEach` clears localStorage + * for the next test, leaking entries that re-trigger the migration + * path. Unique session ids sidestep the race. + */ + +type HubEntry = { entryId: string; text: string; createdAt: number; updatedAt: number } + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false } + } + }) + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function createMockApi(overrides: Partial<{ + getScratchlist: (sessionId: string) => Promise<{ entries: HubEntry[] }> + createScratchlistEntry: (sessionId: string, body: { text: string; entryId?: string; createdAt?: number }) => Promise<{ entry: HubEntry }> + updateScratchlistEntry: (sessionId: string, entryId: string, text: string) => Promise<{ entry: HubEntry }> + deleteScratchlistEntry: (sessionId: string, entryId: string) => Promise +}> = {}): ApiClient { + return { + getScratchlist: overrides.getScratchlist ?? (async () => ({ entries: [] })), + createScratchlistEntry: overrides.createScratchlistEntry + ?? (async (_sessionId, body) => ({ + entry: { + entryId: body.entryId ?? `auto-${Math.random()}`, + text: body.text, + createdAt: body.createdAt ?? Date.now(), + updatedAt: Date.now() + } + })), + updateScratchlistEntry: overrides.updateScratchlistEntry + ?? (async (_sessionId, entryId, text) => ({ + entry: { entryId, text, createdAt: 1000, updatedAt: 5000 } + })), + deleteScratchlistEntry: overrides.deleteScratchlistEntry ?? (async () => undefined) + } as unknown as ApiClient +} + +let nextSessionIdCounter = 0 +function makeSid(): string { + nextSessionIdCounter += 1 + return `s-${nextSessionIdCounter}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + localStorage.clear() + vi.restoreAllMocks() +}) + +describe('useHubScratchlist - initial fetch', () => { + it('exposes entries returned by the hub', async () => { + const sid = makeSid() + const api = createMockApi({ + getScratchlist: async () => ({ + entries: [ + { entryId: 'a', text: 'first', createdAt: 1000, updatedAt: 1000 }, + { entryId: 'b', text: 'second', createdAt: 2000, updatedAt: 2000 } + ] + }) + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.entries.length).toBe(2)) + expect(result.current.entries.map((e) => e.id)).toEqual(['a', 'b']) + }) +}) + +describe('useHubScratchlist - add', () => { + it('optimistically inserts the new entry then reconciles with the hub-returned row', async () => { + const sid = makeSid() + const api = createMockApi({ + getScratchlist: async () => ({ entries: [] }), + createScratchlistEntry: async (_s, body) => ({ + entry: { entryId: 'hub-id', text: body.text, createdAt: 5000, updatedAt: 5000 } + }) + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + let added: boolean | undefined + await act(async () => { + added = await result.current.add('new note') + }) + expect(added).toBe(true) + await waitFor(() => expect(result.current.entries.length).toBe(1)) + expect(result.current.entries[0]?.id).toBe('hub-id') + expect(result.current.entries[0]?.text).toBe('new note') + }) + + it('rolls back when the hub rejects the create', async () => { + const sid = makeSid() + const api = createMockApi({ + getScratchlist: async () => ({ + entries: [{ entryId: 'a', text: 'existing', createdAt: 1000, updatedAt: 1000 }] + }), + createScratchlistEntry: async () => { + throw new Error('HTTP 500') + } + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.entries.length).toBe(1)) + + let added: boolean | undefined + await act(async () => { + added = await result.current.add('doomed') + }) + expect(added).toBe(false) + // After rollback, the original list is intact (no optimistic ghost). + expect(result.current.entries.map((e) => e.id)).toEqual(['a']) + }) + + it('refuses to add empty text without calling the hub', async () => { + const sid = makeSid() + const create = vi.fn(async () => ({ + entry: { entryId: 'x', text: '', createdAt: 0, updatedAt: 0 } + })) + const api = createMockApi({ + getScratchlist: async () => ({ entries: [] }), + createScratchlistEntry: create + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + let added: boolean | undefined + await act(async () => { + added = await result.current.add(' ') + }) + expect(added).toBe(false) + expect(create).not.toHaveBeenCalled() + }) + + it('refuses to add when at the 200-entry cap', async () => { + const sid = makeSid() + const existing: HubEntry[] = Array.from({ length: 200 }, (_, i) => ({ + entryId: `id-${i}`, + text: `note-${i}`, + createdAt: i, + updatedAt: i + })) + const create = vi.fn() + const api = createMockApi({ + getScratchlist: async () => ({ entries: existing }), + createScratchlistEntry: create as never + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.entries.length).toBe(200)) + + let added: boolean | undefined + await act(async () => { + added = await result.current.add('overflow') + }) + expect(added).toBe(false) + expect(create).not.toHaveBeenCalled() + }) +}) + +describe('useHubScratchlist - delete', () => { + it('optimistically removes the entry and survives a network error via rollback', async () => { + const sid = makeSid() + const api = createMockApi({ + getScratchlist: async () => ({ + entries: [ + { entryId: 'a', text: 'A', createdAt: 1, updatedAt: 1 }, + { entryId: 'b', text: 'B', createdAt: 2, updatedAt: 2 } + ] + }), + deleteScratchlistEntry: async () => { + throw new Error('HTTP 500') + } + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.entries.length).toBe(2)) + + await act(async () => { + await result.current.remove('a') + }) + // After rollback the entry is restored. + await waitFor(() => expect(result.current.entries.length).toBe(2)) + expect(result.current.entries.map((e) => e.id).sort()).toEqual(['a', 'b']) + }) +}) + +describe('useHubScratchlist - update', () => { + it('optimistically updates text and reconciles with the hub-returned row', async () => { + const sid = makeSid() + const api = createMockApi({ + getScratchlist: async () => ({ + entries: [{ entryId: 'a', text: 'before', createdAt: 1, updatedAt: 1 }] + }), + updateScratchlistEntry: async (_s, entryId, text) => ({ + entry: { entryId, text, createdAt: 1, updatedAt: 5 } + }) + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.entries.length).toBe(1)) + + await act(async () => { + await result.current.update('a', 'after') + }) + await waitFor(() => expect(result.current.entries[0]?.text).toBe('after')) + }) +}) + +describe('useHubScratchlist - localStorage migration', () => { + function seedV1Entries(sid: string) { + localStorage.setItem( + `hapi.scratchlist.v1.${sid}`, + JSON.stringify([ + { id: 'old-1', text: 'pre-v2 note', createdAt: 100 }, + { id: 'old-2', text: 'another', createdAt: 200 } + ]) + ) + } + + it('uploads localStorage entries when the hub returns empty and flips status to completed', async () => { + const sid = makeSid() + seedV1Entries(sid) + const create = vi.fn(async (_s: string, body: { text: string; entryId?: string; createdAt?: number }) => ({ + entry: { + entryId: body.entryId ?? 'fresh', + text: body.text, + createdAt: body.createdAt ?? 999, + updatedAt: 999 + } + })) + let fetchCount = 0 + const api = createMockApi({ + getScratchlist: async () => { + fetchCount += 1 + if (fetchCount === 1) { + return { entries: [] } + } + return { + entries: [ + { entryId: 'old-1', text: 'pre-v2 note', createdAt: 100, updatedAt: 100 }, + { entryId: 'old-2', text: 'another', createdAt: 200, updatedAt: 200 } + ] + } + }, + createScratchlistEntry: create + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.migrationStatus).toBe('completed')) + expect(create).toHaveBeenCalledTimes(2) + const entryIds = create.mock.calls.map((c) => (c[1] as { entryId?: string }).entryId) + expect(entryIds.sort()).toEqual(['old-1', 'old-2']) + expect(localStorage.getItem(`hapi.scratchlist.v2.migrated.${sid}`)).toBe('1') + }) + + it('does not re-migrate on a mount where the migrated flag is already set', async () => { + const sid = makeSid() + seedV1Entries(sid) + localStorage.setItem(`hapi.scratchlist.v2.migrated.${sid}`, '1') + const create = vi.fn() + const api = createMockApi({ + getScratchlist: async () => ({ entries: [] }), + createScratchlistEntry: create as never + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + await new Promise((r) => setTimeout(r, 30)) + expect(create).not.toHaveBeenCalled() + expect(['pre-migrated', 'idle']).toContain(result.current.migrationStatus) + }) + + it('dismissMigrationBanner persists the dismissal flag and flips status to dismissed', async () => { + const sid = makeSid() + seedV1Entries(sid) + const api = createMockApi({ + getScratchlist: async () => ({ + entries: [{ entryId: 'old-1', text: 'pre-v2 note', createdAt: 100, updatedAt: 100 }] + }), + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.entries.length).toBe(1)) + + // Dismissal is independent of how `completed` was reached - the + // status flip is what matters for banner visibility (the banner + // only renders for `completed`, so dismissing flips it off). + act(() => { + result.current.dismissMigrationBanner() + }) + expect(result.current.migrationStatus).toBe('dismissed') + expect(localStorage.getItem(`hapi.scratchlist.v2.banner-dismissed.${sid}`)).toBe('1') + }) + + it('skips migration when localStorage is empty but still sets the flag (so future loads do not probe again)', async () => { + const sid = makeSid() + const create = vi.fn() + const api = createMockApi({ + getScratchlist: async () => ({ entries: [] }), + createScratchlistEntry: create as never + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(localStorage.getItem(`hapi.scratchlist.v2.migrated.${sid}`)).toBe('1')) + expect(create).not.toHaveBeenCalled() + expect(result.current.migrationStatus).toBe('idle') + }) +}) + +describe('useHubScratchlist - reorder (local-only)', () => { + it('move() reorders entries in-place without calling the hub', async () => { + const sid = makeSid() + const updateMock = vi.fn() + const api = createMockApi({ + getScratchlist: async () => ({ + entries: [ + { entryId: 'top', text: 'top', createdAt: 100, updatedAt: 100 }, + { entryId: 'bot', text: 'bot', createdAt: 50, updatedAt: 50 } + ] + }), + updateScratchlistEntry: updateMock as never + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(result.current.entries.length).toBe(2)) + expect(result.current.entries.map((e) => e.id)).toEqual(['top', 'bot']) + + await act(async () => { + result.current.move('bot', 'up') + }) + await waitFor(() => { + expect(result.current.entries.map((e) => e.id)).toEqual(['bot', 'top']) + }) + expect(updateMock).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/lib/use-hub-scratchlist.ts b/web/src/lib/use-hub-scratchlist.ts new file mode 100644 index 0000000000..ca17b07296 --- /dev/null +++ b/web/src/lib/use-hub-scratchlist.ts @@ -0,0 +1,465 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import { queryKeys } from '@/lib/query-keys' +import { + moveScratchlistEntry, + readScratchlist, + SCRATCHLIST_MAX_ENTRIES, + SCRATCHLIST_MAX_TEXT_LENGTH, + type ScratchlistEntry, +} from '@/lib/scratchlist' + +/** + * tiann/hapi#893 (scratchlist v2): hub-synced replacement for the v1 + * `useScratchlist` localStorage-only hook. + * + * Source-of-truth shift + * --------------------- + * v1: `localStorage` was canonical, persisted on every mutation, read on + * mount. v2: hub becomes canonical (durable + cross-device); localStorage + * is demoted to an offline cache. This hook fetches via TanStack Query + * keyed by `queryKeys.scratchlist(sessionId)`; the SSE handler in + * `useSSE.ts` invalidates that key when a `session-updated` patch + * carries `scratchlistUpdatedAt`, so a write in tab A surfaces in tab B + * within ~1 SSE round-trip. + * + * Optimistic mutations + * -------------------- + * Add/delete/update apply optimistically to the cached entries list and + * roll back on error using TanStack's `onMutate` / `onError` snapshot + * pattern. The server returns the canonical row (with hub-stamped + * `updatedAt`) on success and we reconcile. + * + * Reorder (move) + * -------------- + * Reorder is local-only in v2.0: the hub stores entries with stable + * `createdAt` (used by future overseer queries per operator decision), + * and adding a `position` column / cross-device order semantics is a + * v2.1 concern. The move is applied to the cached array client-side; a + * subsequent invalidation refetch will reset the order. This is a + * documented limitation, not a bug - see `tiann/hapi#893` body. + * + * Migration on first v2-load + * -------------------------- + * When the hook mounts on a session that has localStorage entries from + * v1 AND the hub returns no entries AND the per-session migration flag + * has not been set, we push the localStorage entries up via POST, + * preserving their original `id` and `createdAt`. The flag + * `hapi.scratchlist.v2.migrated.${sessionId}` then prevents repeated + * migrations across reloads. The per-session banner status reflects + * whether the migration just ran (`completed`) or was acknowledged + * (`dismissed`); the banner component listens for this signal. + */ + +const MIGRATION_FLAG_PREFIX = 'hapi.scratchlist.v2.migrated.' +const MIGRATION_BANNER_DISMISSED_PREFIX = 'hapi.scratchlist.v2.banner-dismissed.' + +export type ScratchlistMigrationStatus = + | 'idle' // no localStorage entries; nothing to migrate + | 'migrating' // POSTs in flight + | 'completed' // migration ran in this mount; banner should show + | 'dismissed' // banner was acknowledged; do not surface again + | 'pre-migrated' // migration was completed in a prior session and the user already saw the banner + +type HubEntry = { + entryId: string + text: string + createdAt: number + updatedAt: number +} + +type ScratchlistResponse = { entries: HubEntry[] } + +function readMigrationFlag(sessionId: string): boolean { + if (typeof window === 'undefined') return false + try { + return window.localStorage.getItem(`${MIGRATION_FLAG_PREFIX}${sessionId}`) === '1' + } catch { + return false + } +} + +function writeMigrationFlag(sessionId: string): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(`${MIGRATION_FLAG_PREFIX}${sessionId}`, '1') + } catch { + // Storage quota / private mode: non-fatal. Worst case the migration + // re-runs next mount; the hub returns 200/duplicate for collisions + // (see hub/src/store/scratchlist.ts createScratchlistEntry). + } +} + +function readBannerDismissed(sessionId: string): boolean { + if (typeof window === 'undefined') return false + try { + return window.localStorage.getItem(`${MIGRATION_BANNER_DISMISSED_PREFIX}${sessionId}`) === '1' + } catch { + return false + } +} + +function writeBannerDismissed(sessionId: string): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(`${MIGRATION_BANNER_DISMISSED_PREFIX}${sessionId}`, '1') + } catch { + // Non-fatal: banner reappears on next mount until storage works. + } +} + +/** + * Convert hub entries into the in-memory shape the panel components + * expect (`ScratchlistEntry` from `lib/scratchlist.ts`). Hub `entryId` + * maps to local `id`. Hub `updatedAt` is dropped from the local view + * because v1 components don't render it - it's tracked in the cache for + * SSE reconciliation, not in the props. + */ +function toLocalEntry(hub: HubEntry): ScratchlistEntry { + return { + id: hub.entryId, + text: hub.text, + createdAt: hub.createdAt + } +} + +function makeOptimisticHubEntry(text: string, now: number): HubEntry { + const fallbackId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `scratch-${now}-${Math.random().toString(36).slice(2, 10)}` + return { + entryId: fallbackId, + text, + createdAt: now, + updatedAt: now + } +} + +export function useHubScratchlist( + sessionId: string, + api: ApiClient | null +): { + entries: ScratchlistEntry[] + isLoading: boolean + add: (text: string) => Promise + remove: (id: string) => Promise + update: (id: string, text: string) => Promise + move: (id: string, direction: 'up' | 'down') => void + migrationStatus: ScratchlistMigrationStatus + dismissMigrationBanner: () => void +} { + const queryClient = useQueryClient() + const queryKey = queryKeys.scratchlist(sessionId) + const enabled = Boolean(api && sessionId) + const migrationAttemptedRef = useRef(false) + const [migrationStatus, setMigrationStatus] = useState(() => { + if (!sessionId) return 'idle' + if (readBannerDismissed(sessionId)) return 'dismissed' + if (readMigrationFlag(sessionId)) return 'pre-migrated' + return 'idle' + }) + + const query = useQuery({ + queryKey, + queryFn: async () => { + if (!api || !sessionId) { + return { entries: [] } + } + return await api.getScratchlist(sessionId) + }, + enabled, + // 30s - matches `useSession` cache freshness so cross-tab SSE + // invalidation is the dominant refresh signal, not stale-time + // expiry. + staleTime: 30_000, + }) + + // Reset migration tracking when the session id changes. The ref-based + // gate prevents the migration effect from re-firing on every render + // for the same session even if the query data fluctuates between + // empty and non-empty during in-flight optimistic add/rollback. + useEffect(() => { + migrationAttemptedRef.current = false + if (!sessionId) { + setMigrationStatus('idle') + return + } + if (readBannerDismissed(sessionId)) { + setMigrationStatus('dismissed') + } else if (readMigrationFlag(sessionId)) { + setMigrationStatus('pre-migrated') + } else { + setMigrationStatus('idle') + } + }, [sessionId]) + + // Migration trigger: runs ONCE per session when: + // - api is available + // - hub returned an empty list + // - migration flag is unset + // - localStorage holds v1 entries + // The actual POSTs are sequential to keep retry semantics simple + // and to avoid bursts that could trip rate-limit guards. For the + // typical case of "a handful of stale entries" this is fine. + useEffect(() => { + if (!api || !sessionId) return + if (migrationAttemptedRef.current) return + if (query.isLoading || query.isFetching) return + if (!query.data) return + if (query.data.entries.length > 0) return + if (readMigrationFlag(sessionId)) return + + const localEntries = readScratchlist(sessionId) + if (localEntries.length === 0) { + // Nothing to migrate but we still mark the session migrated + // so subsequent loads skip the localStorage probe. + writeMigrationFlag(sessionId) + return + } + + migrationAttemptedRef.current = true + setMigrationStatus('migrating') + + void (async () => { + try { + // Preserve creation order by POSTing in the order + // localStorage holds them. The hub orders by createdAt + // DESC at read time, so source order doesn't actually + // matter for visual layout - but we keep it deterministic + // for the migration retry path. + for (const entry of localEntries) { + const text = entry.text.length > SCRATCHLIST_MAX_TEXT_LENGTH + ? entry.text.slice(0, SCRATCHLIST_MAX_TEXT_LENGTH) + : entry.text + if (text.trim().length === 0) continue + try { + await api.createScratchlistEntry(sessionId, { + text, + entryId: entry.id, + createdAt: entry.createdAt + }) + } catch { + // Per-entry failure is non-fatal: the hub returns + // 200 with the canonical row for duplicates, and + // any genuine rejection (e.g. cap reached) just + // drops the migrated entry. Logging to console + // would be noise; the user can re-add manually. + } + } + writeMigrationFlag(sessionId) + await queryClient.invalidateQueries({ queryKey }) + setMigrationStatus('completed') + } catch { + // Whole-flow failure (network out, etc): leave the flag + // unset so a future mount retries; clear the banner + // status so we don't show "completed" for a half-done + // migration. + migrationAttemptedRef.current = false + setMigrationStatus('idle') + } + })() + }, [api, sessionId, query.data, query.isLoading, query.isFetching, queryClient, queryKey]) + + const dismissMigrationBanner = useCallback(() => { + writeBannerDismissed(sessionId) + setMigrationStatus('dismissed') + }, [sessionId]) + + const addMutation = useMutation< + { entry: HubEntry }, + Error, + { text: string }, + { previousData: ScratchlistResponse | undefined; optimisticEntryId: string } + >({ + mutationFn: async ({ text }) => { + if (!api || !sessionId) throw new Error('Scratchlist unavailable') + return await api.createScratchlistEntry(sessionId, { text }) + }, + onMutate: async ({ text }) => { + await queryClient.cancelQueries({ queryKey }) + const previousData = queryClient.getQueryData(queryKey) + const optimistic = makeOptimisticHubEntry(text, Date.now()) + queryClient.setQueryData(queryKey, (prev) => { + const prior = prev?.entries ?? [] + return { entries: [optimistic, ...prior] } + }) + return { previousData, optimisticEntryId: optimistic.entryId } + }, + onError: (_error, _variables, context) => { + if (context?.previousData !== undefined) { + queryClient.setQueryData(queryKey, context.previousData) + } + }, + onSuccess: (data, _variables, context) => { + // Replace the optimistic entry with the hub-canonical row so + // subsequent updates target the real entryId. If the cache + // already invalidated (SSE round-trip beat the response), + // the canonical row will arrive via refetch anyway. + queryClient.setQueryData(queryKey, (prev) => { + if (!prev) return { entries: [data.entry] } + const without = prev.entries.filter((e) => e.entryId !== context?.optimisticEntryId) + return { entries: [data.entry, ...without] } + }) + } + }) + + const updateMutation = useMutation< + { entry: HubEntry }, + Error, + { entryId: string; text: string }, + { previousData: ScratchlistResponse | undefined } + >({ + mutationFn: async ({ entryId, text }) => { + if (!api || !sessionId) throw new Error('Scratchlist unavailable') + return await api.updateScratchlistEntry(sessionId, entryId, text) + }, + onMutate: async ({ entryId, text }) => { + await queryClient.cancelQueries({ queryKey }) + const previousData = queryClient.getQueryData(queryKey) + const now = Date.now() + queryClient.setQueryData(queryKey, (prev) => { + if (!prev) return prev + return { + entries: prev.entries.map((e) => + e.entryId === entryId ? { ...e, text, updatedAt: now } : e + ) + } + }) + return { previousData } + }, + onError: (_error, _variables, context) => { + if (context?.previousData !== undefined) { + queryClient.setQueryData(queryKey, context.previousData) + } + } + }) + + const deleteMutation = useMutation< + void, + Error, + { entryId: string }, + { previousData: ScratchlistResponse | undefined } + >({ + mutationFn: async ({ entryId }) => { + if (!api || !sessionId) throw new Error('Scratchlist unavailable') + await api.deleteScratchlistEntry(sessionId, entryId) + }, + onMutate: async ({ entryId }) => { + await queryClient.cancelQueries({ queryKey }) + const previousData = queryClient.getQueryData(queryKey) + queryClient.setQueryData(queryKey, (prev) => { + if (!prev) return prev + return { entries: prev.entries.filter((e) => e.entryId !== entryId) } + }) + return { previousData } + }, + onError: (_error, _variables, context) => { + if (context?.previousData !== undefined) { + queryClient.setQueryData(queryKey, context.previousData) + } + } + }) + + const add = useCallback(async (rawText: string): Promise => { + const text = rawText.trim() + if (text.length === 0) return false + const truncated = text.length > SCRATCHLIST_MAX_TEXT_LENGTH + ? text.slice(0, SCRATCHLIST_MAX_TEXT_LENGTH) + : text + const current = queryClient.getQueryData(queryKey)?.entries ?? [] + if (current.length >= SCRATCHLIST_MAX_ENTRIES) { + return false + } + try { + await addMutation.mutateAsync({ text: truncated }) + return true + } catch { + return false + } + }, [addMutation, queryClient, queryKey]) + + const remove = useCallback(async (id: string) => { + try { + await deleteMutation.mutateAsync({ entryId: id }) + } catch { + // Rollback already happened in onError; surface to caller via + // the rejected promise would force the panel to add error UI + // we don't have copy for. Swallow here; SSE refetch on next + // hub state change will reconcile. + } + }, [deleteMutation]) + + const updateEntry = useCallback(async (id: string, rawText: string) => { + const text = rawText.trim() + if (text.length === 0) return + const truncated = text.length > SCRATCHLIST_MAX_TEXT_LENGTH + ? text.slice(0, SCRATCHLIST_MAX_TEXT_LENGTH) + : text + try { + await updateMutation.mutateAsync({ entryId: id, text: truncated }) + } catch { + // see `remove` rationale. + } + }, [updateMutation]) + + /** + * Local-only reorder. Mutates the cached array so the UI updates + * immediately; no hub call. The next invalidation refetch will reset + * the order to `createdAt DESC` - documented limitation per + * `tiann/hapi#893`. (v2.1 may add a `position` column.) + */ + const move = useCallback((id: string, direction: 'up' | 'down') => { + queryClient.setQueryData(queryKey, (prev) => { + if (!prev) return prev + const local = prev.entries.map(toLocalEntry) + const reordered = moveScratchlistEntry(local, id, direction) + // Rebuild the hub-shaped list using the reordered ids while + // preserving each entry's hub-stamped fields. Map by id for + // O(1) lookup. + const byId = new Map(prev.entries.map((e) => [e.entryId, e] as const)) + const next: HubEntry[] = [] + for (const r of reordered) { + const hub = byId.get(r.id) + if (hub) next.push(hub) + } + return { entries: next } + }) + }, [queryClient, queryKey]) + + // Mirror entries into localStorage as an offline cache. Keeps the v1 + // surface (e.g. the standalone `ScratchlistPanel` used by tests) + // working when offline, and protects against losing freshly-added + // entries if the hub goes away mid-session. + useEffect(() => { + if (!sessionId) return + const data = query.data + if (!data) return + try { + const cached = data.entries.map((e) => ({ + id: e.entryId, + text: e.text, + createdAt: e.createdAt + })) + window.localStorage.setItem( + `hapi.scratchlist.v1.${sessionId}`, + JSON.stringify(cached) + ) + } catch { + // Non-fatal: storage quota / private mode. + } + }, [sessionId, query.data]) + + const entries: ScratchlistEntry[] = (query.data?.entries ?? []).map(toLocalEntry) + + return { + entries, + isLoading: query.isLoading, + add, + remove, + update: updateEntry, + move, + migrationStatus, + dismissMigrationBanner + } +} diff --git a/web/src/lib/use-scratchlist-count.ts b/web/src/lib/use-scratchlist-count.ts new file mode 100644 index 0000000000..291db91ad8 --- /dev/null +++ b/web/src/lib/use-scratchlist-count.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import { queryKeys } from '@/lib/query-keys' + +/** + * tiann/hapi#893: read-only count of scratchlist entries for a session. + * + * Reuses the same TanStack Query cache key as `useHubScratchlist`, so + * the cost of calling it here in `SessionHeader` is zero when the same + * session is rendered in `SessionChat` - both components share one + * fetch. + * + * Used by the delete-session confirmation dialog to surface + * "this will also delete N scratchlist entries" copy. The signal is the + * count, not the entries themselves; we deliberately do not list them + * inline because the list could be long and would compete with the + * confirm action for attention. + */ +export function useScratchlistCount(sessionId: string, api: ApiClient | null): number { + const query = useQuery<{ entries: Array }>({ + queryKey: queryKeys.scratchlist(sessionId), + queryFn: async () => { + if (!api) return { entries: [] } + return await api.getScratchlist(sessionId) + }, + enabled: Boolean(api && sessionId), + staleTime: 30_000, + }) + return query.data?.entries.length ?? 0 +} From 92862f82c7adef444c35670a28578bbc6f99b186 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:54:06 +0100 Subject: [PATCH 002/128] feat(web): rich hover tooltips on session-list attention indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-row attention dots and the future-scheduled clock icon used plain `title=""` attributes which gave only a one-word label ("Permission required"). Replace those with hover/focus-revealed tooltips that name *which* tools are blocking, count background tasks, surface the "updated Nm ago" timestamp, and explain the pending schedule. To make per-tool copy possible without an extra round trip, `SessionSummary` now carries a structured slice of the pending tool requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first: pendingRequests: Array<{ id; kind; tool; since }> `pendingRequestsCount` remains the authoritative total; `pendingRequestKinds` is still derived from the FULL request set so a single `'input'` request beyond the cap still surfaces its kind on the session row. The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no portal, no positioning JS — so it composes cheaply inside the existing session-row `
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5a0aba4e82..5d9c1d6f15 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -110,6 +110,13 @@ export default { 'session.item.newActivity': 'New activity', 'session.item.scheduledMessage': 'Scheduled message pending', 'session.item.scheduledMessages': '{count} scheduled messages pending', + 'session.tooltip.permission.body': 'Approve:', + 'session.tooltip.input.body': 'Reply to:', + 'session.tooltip.background.count.one': '1 task running', + 'session.tooltip.background.count.other': '{count} tasks running', + 'session.tooltip.unread.body': 'Updated {time}', + 'session.tooltip.scheduled.body': 'Will fire when due.', + 'session.tooltip.moreCount': '+{count} more', 'session.time.justNow': 'just now', 'session.time.minutesAgo': '{n}m ago', 'session.time.hoursAgo': '{n}h ago', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d3cecec503..241545c618 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -110,6 +110,13 @@ export default { 'session.item.newActivity': '有新活动', 'session.item.scheduledMessage': '有待发送的定时消息', 'session.item.scheduledMessages': '{count} 条定时消息待发送', + 'session.tooltip.permission.body': '批准:', + 'session.tooltip.input.body': '回复:', + 'session.tooltip.background.count.one': '1 个任务运行中', + 'session.tooltip.background.count.other': '{count} 个任务运行中', + 'session.tooltip.unread.body': '更新于 {time}', + 'session.tooltip.scheduled.body': '到时会自动发送。', + 'session.tooltip.moreCount': '另有 {count} 条', 'session.time.justNow': '刚刚', 'session.time.minutesAgo': '{n} 分钟前', 'session.time.hoursAgo': '{n} 小时前', diff --git a/web/src/lib/relativeTime.ts b/web/src/lib/relativeTime.ts new file mode 100644 index 0000000000..31abbb13c8 --- /dev/null +++ b/web/src/lib/relativeTime.ts @@ -0,0 +1,22 @@ +/** + * Formats an epoch ms / s value as a localised "Nm ago" / "Nh ago" / date label. + * Accepts both ms and seconds; values smaller than 1e12 are treated as seconds. + * + * Returns `null` when the input is not finite. + */ +export function formatRelativeTime( + value: number, + t: (key: string, params?: Record) => string +): string | null { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + if (!Number.isFinite(ms)) return null + const delta = Date.now() - ms + if (delta < 60_000) return t('session.time.justNow') + const minutes = Math.floor(delta / 60_000) + if (minutes < 60) return t('session.time.minutesAgo', { n: minutes }) + const hours = Math.floor(minutes / 60) + if (hours < 24) return t('session.time.hoursAgo', { n: hours }) + const days = Math.floor(hours / 24) + if (days < 7) return t('session.time.daysAgo', { n: days }) + return new Date(ms).toLocaleDateString() +} diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index bc6c7d6e64..bac9caf2f8 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -12,6 +12,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi todoProgress: null, pendingRequestsCount: 0, pendingRequestKinds: [], + pendingRequests: [], backgroundTaskCount: 0, futureScheduledMessageCount: 0, model: null, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index b9f5d0f762..2d77fb0a5f 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -43,6 +43,8 @@ export type { Metadata, PermissionMode, Machine, + PendingRequest, + PendingRequestKind, RunnerState, Session, SessionPatch, From 29a0f6ee4376e00c4ab541db28e2a0c98fbca7ee Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:18:50 +0100 Subject: [PATCH 003/128] fix(scratchlist): address HAPI Bot Major findings on PR #896 Two real data-correctness paths the bot caught on the initial review. 1. Migration partial-failure data loss The migration loop swallowed each failed POST and still wrote the `migrated` flag, while the offline-cache effect mirrored the (partial) hub state back into `hapi.scratchlist.v1.` - so a transient error or cap rejection could leave entries neither on the hub nor in localStorage. Fix: - Track failed entries during migration and persist them back to localStorage; do NOT advance the flag if any entry failed, so a future mount retries. - Gate the offline-cache effect on the migration flag. Pre- migration, localStorage holds the v1 entries the migration reads; mirroring an empty hub fetch over them was the wipe. - Drop the "skip migration when hub is non-empty" gate. Combined with the duplicate-idempotent POST short-circuit (below), a retry against a session that another device already populated is a safe union. 2. Duplicate POST returned 409 at cap The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking the store whether the supplied `entryId` already existed, so an idempotent migration retry against a 200-row session returned 409 instead of 200. Fix: check duplicate first via a new `SyncEngine.getScratchlistEntry`, return the existing row with 200, and only run the cap check for genuinely new ids. Tests added: - hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap + new entryId still 409. - web/hook: partial-failure persists the failed entries back to localStorage and leaves the flag unset; offline-cache effect does not wipe pre-migration localStorage. Co-authored-by: Cursor --- hub/src/sync/syncEngine.ts | 21 +++++ .../web/routes/sessions-scratchlist.test.ts | 61 ++++++++++++++ hub/src/web/routes/sessions.ts | 17 ++++ web/src/lib/use-hub-scratchlist.test.tsx | 80 +++++++++++++++++++ web/src/lib/use-hub-scratchlist.ts | 64 +++++++++++---- 5 files changed, 228 insertions(+), 15 deletions(-) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 739d415e44..67f227f8fc 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -353,6 +353,27 @@ export class SyncEngine { return this.store.scratchlist.count(sessionId) } + /** + * Read a single entry by id. The route layer uses this to short- + * circuit duplicate POSTs (migration retry) BEFORE running the + * server-side cap check; otherwise an idempotent retry against a + * session that has hit `SCRATCHLIST_MAX_ENTRIES` would 409 when it + * should 200 with the existing row. + */ + getScratchlistEntry( + sessionId: string, + entryId: string + ): { entryId: string; text: string; createdAt: number; updatedAt: number } | null { + const row = this.store.scratchlist.get(sessionId, entryId) + if (!row) return null + return { + entryId: row.entryId, + text: row.text, + createdAt: row.createdAt, + updatedAt: row.updatedAt + } + } + /** * Insert a scratchlist entry. Returns the canonical row on success * (so the route layer can serialise it without a follow-up read). diff --git a/hub/src/web/routes/sessions-scratchlist.test.ts b/hub/src/web/routes/sessions-scratchlist.test.ts index 33e2b7717a..0ba3cbb57f 100644 --- a/hub/src/web/routes/sessions-scratchlist.test.ts +++ b/hub/src/web/routes/sessions-scratchlist.test.ts @@ -61,6 +61,7 @@ function createSession(overrides?: Partial): Session { type EngineOverrides = Partial<{ listScratchlistEntries: SyncEngine['listScratchlistEntries'] countScratchlistEntries: SyncEngine['countScratchlistEntries'] + getScratchlistEntry: SyncEngine['getScratchlistEntry'] createScratchlistEntry: SyncEngine['createScratchlistEntry'] updateScratchlistEntry: SyncEngine['updateScratchlistEntry'] deleteScratchlistEntry: SyncEngine['deleteScratchlistEntry'] @@ -81,6 +82,7 @@ function createApp(session: Session, overrides: EngineOverrides = {}) { }, listScratchlistEntries: overrides.listScratchlistEntries ?? (() => []), countScratchlistEntries: overrides.countScratchlistEntries ?? (() => 0), + getScratchlistEntry: overrides.getScratchlistEntry ?? (() => null), createScratchlistEntry: overrides.createScratchlistEntry ?? ((sessionId: string, text: string) => ({ outcome: 'created' as const, @@ -225,6 +227,65 @@ describe('POST /api/sessions/:id/scratchlist', () => { expect(body.code).toBe('scratchlist_at_cap') }) + it('still returns 200 for a duplicate entryId even when the session is at the cap (HAPI Bot, PR #896)', async () => { + // The cap check used to fire BEFORE the duplicate check, which + // turned an idempotent migration retry into a hard 409 the + // moment a session reached `SCRATCHLIST_MAX_ENTRIES`. The fix + // short-circuits on getScratchlistEntry first; this test pins + // that ordering. + const session = createSession() + const createCalls: number[] = [] + const app = createApp(session, { + countScratchlistEntries: () => 200, + getScratchlistEntry: (_sessionId, entryId) => { + if (entryId === 'pre-existing') { + return { + entryId: 'pre-existing', + text: 'already there', + createdAt: 100, + updatedAt: 100 + } + } + return null + }, + createScratchlistEntry: () => { + createCalls.push(1) + return { + outcome: 'created' as const, + entry: { entryId: 'should-not-fire', text: 'noop', createdAt: 0, updatedAt: 0 } + } + } + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'replay', entryId: 'pre-existing' }) + }) + expect(res.status).toBe(200) + const body = await res.json() as { entry: { text: string; entryId: string } } + expect(body.entry.entryId).toBe('pre-existing') + expect(body.entry.text).toBe('already there') + // The route must NOT have called createScratchlistEntry: the + // duplicate short-circuit returns BEFORE reaching the engine. + expect(createCalls).toHaveLength(0) + }) + + it('still returns 409 for a NEW entryId at the cap', async () => { + // Mirror of the test above for the not-duplicate case: a fresh + // POST at cap stays a 409. + const session = createSession() + const app = createApp(session, { + countScratchlistEntries: () => 200, + getScratchlistEntry: () => null + }) + const res = await app.request('/api/sessions/session-1/scratchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'fresh', entryId: 'never-seen' }) + }) + expect(res.status).toBe(409) + }) + it('returns 404 when the engine reports session-not-found post-auth', async () => { // This path covers a race: auth said the session was visible // (resolveSessionAccess.ok), but by the time we INSERT the row the diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 977073fcac..0ea645ba9a 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -652,6 +652,23 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) } + // Idempotent-retry short-circuit (HAPI Bot, PR #896 review): + // when the caller supplies an explicit entryId AND that id + // already exists, return the canonical row with 200 BEFORE the + // cap check fires. Otherwise a session sitting at the + // 200-entry cap would 409 a duplicate POST that should be a + // no-op - which is exactly the path the localStorage migration + // retry uses after a partial failure. + if (parsed.data.entryId) { + const existing = engine.getScratchlistEntry( + sessionResult.sessionId, + parsed.data.entryId + ) + if (existing) { + return c.json({ entry: existing }, 200) + } + } + // Server-side cap enforcement. Mirrors the web-side cap so a // malicious / runaway client can't drive the table without // bound. Bypassing the optimistic add path on the web client diff --git a/web/src/lib/use-hub-scratchlist.test.tsx b/web/src/lib/use-hub-scratchlist.test.tsx index 74883ac824..b69683ea42 100644 --- a/web/src/lib/use-hub-scratchlist.test.tsx +++ b/web/src/lib/use-hub-scratchlist.test.tsx @@ -330,6 +330,86 @@ describe('useHubScratchlist - localStorage migration', () => { expect(create).not.toHaveBeenCalled() expect(result.current.migrationStatus).toBe('idle') }) + + it('persists FAILED entries back to localStorage and leaves the flag unset (HAPI Bot, PR #896)', async () => { + // Migration partial failure: 2 entries in localStorage, the + // first POST succeeds and the second throws. Per the bot + // review, the failed entry must be written back to + // localStorage and the migration flag must NOT advance, so a + // future mount can retry. The status drops back to 'idle' + // (banner does not render). + const sid = makeSid() + seedV1Entries(sid) + let postCall = 0 + const create = vi.fn(async (_s: string, body: { text: string; entryId?: string; createdAt?: number }) => { + postCall += 1 + if (postCall === 1) { + return { + entry: { + entryId: body.entryId ?? 'a', + text: body.text, + createdAt: body.createdAt ?? 0, + updatedAt: 0 + } + } + } + throw new Error('HTTP 500: hub flaked on entry 2') + }) + const api = createMockApi({ + getScratchlist: async () => ({ entries: [] }), + createScratchlistEntry: create + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + await waitFor(() => expect(create).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(result.current.migrationStatus).toBe('idle')) + + // Flag must NOT be set: a future mount must retry. + expect(localStorage.getItem(`hapi.scratchlist.v2.migrated.${sid}`)).toBeNull() + // The failed entry (the second one) must be back in localStorage. + const persisted = localStorage.getItem(`hapi.scratchlist.v1.${sid}`) + expect(persisted).not.toBeNull() + const parsed = JSON.parse(persisted!) as Array<{ id: string; text: string }> + expect(parsed.map((e) => e.id)).toEqual(['old-2']) + expect(parsed[0]?.text).toBe('another') + }) + + it('does NOT mirror an empty hub fetch into localStorage before migration runs (HAPI Bot, PR #896)', async () => { + // Pre-fix the offline-cache effect would clobber the v1 + // entries with `[]` the moment the initial fetch returned an + // empty list, racing the migration effect's localStorage + // read on a future mount. The fix gates the cache mirror on + // the migration flag; this test pins it. + const sid = makeSid() + seedV1Entries(sid) + const apiCalls: number[] = [] + const api = createMockApi({ + // Block on first fetch so we can inspect localStorage + // BEFORE the migration effect kicks off. + getScratchlist: async () => { + apiCalls.push(Date.now()) + if (apiCalls.length === 1) { + await new Promise((r) => setTimeout(r, 25)) + return { entries: [] } + } + return { + entries: [ + { entryId: 'old-1', text: 'pre-v2 note', createdAt: 100, updatedAt: 100 }, + { entryId: 'old-2', text: 'another', createdAt: 200, updatedAt: 200 } + ] + } + } + }) + const { result } = renderHook(() => useHubScratchlist(sid, api), { wrapper: createWrapper() }) + // Wait for migration to complete (flag set + status flips). + await waitFor(() => expect(result.current.migrationStatus).toBe('completed'), { timeout: 2000 }) + // localStorage now mirrors hub state (post-migration). It must + // contain the v1 entries that round-tripped through the hub + // fetch, NOT an empty array. + const persisted = localStorage.getItem(`hapi.scratchlist.v1.${sid}`) + expect(persisted).not.toBeNull() + const parsed = JSON.parse(persisted!) as Array<{ id: string }> + expect(parsed.map((e) => e.id).sort()).toEqual(['old-1', 'old-2']) + }) }) describe('useHubScratchlist - reorder (local-only)', () => { diff --git a/web/src/lib/use-hub-scratchlist.ts b/web/src/lib/use-hub-scratchlist.ts index ca17b07296..a6466742ec 100644 --- a/web/src/lib/use-hub-scratchlist.ts +++ b/web/src/lib/use-hub-scratchlist.ts @@ -4,6 +4,7 @@ import type { ApiClient } from '@/api/client' import { queryKeys } from '@/lib/query-keys' import { moveScratchlistEntry, + persistScratchlist, readScratchlist, SCRATCHLIST_MAX_ENTRIES, SCRATCHLIST_MAX_TEXT_LENGTH, @@ -196,18 +197,21 @@ export function useHubScratchlist( // Migration trigger: runs ONCE per session when: // - api is available - // - hub returned an empty list // - migration flag is unset // - localStorage holds v1 entries - // The actual POSTs are sequential to keep retry semantics simple - // and to avoid bursts that could trip rate-limit guards. For the - // typical case of "a handful of stale entries" this is fine. + // Hub being non-empty does NOT block the migration: each POST uses + // the entry's original id and the route returns 200 for an + // already-existing id (idempotent). So a session that another + // device already populated is safely treated as a union with this + // device's local entries. The actual POSTs are sequential to keep + // retry semantics simple and to avoid bursts that could trip + // rate-limit guards. For the typical case of "a handful of stale + // entries" this is fine. useEffect(() => { if (!api || !sessionId) return if (migrationAttemptedRef.current) return if (query.isLoading || query.isFetching) return if (!query.data) return - if (query.data.entries.length > 0) return if (readMigrationFlag(sessionId)) return const localEntries = readScratchlist(sessionId) @@ -222,6 +226,14 @@ export function useHubScratchlist( setMigrationStatus('migrating') void (async () => { + // HAPI Bot review on PR #896 caught a data-loss path here: + // swallowing per-entry POST failures and still writing the + // migration flag would strand entries (the offline-cache + // mirror would replace the original localStorage with the + // partial hub state). Track failed entries and persist them + // back so a future mount retries; do NOT set the flag until + // every local entry is reconciled. + const failedEntries: ScratchlistEntry[] = [] try { // Preserve creation order by POSTing in the order // localStorage holds them. The hub orders by createdAt @@ -240,21 +252,34 @@ export function useHubScratchlist( createdAt: entry.createdAt }) } catch { - // Per-entry failure is non-fatal: the hub returns - // 200 with the canonical row for duplicates, and - // any genuine rejection (e.g. cap reached) just - // drops the migrated entry. Logging to console - // would be noise; the user can re-add manually. + // Genuine rejection (cap, network, 5xx...). The + // hub-side route returns 200 for duplicate + // entryId so an idempotent retry doesn't land + // here; only "really did not stick" failures do. + failedEntries.push(entry) } } + if (failedEntries.length > 0) { + // Write the unsynced subset back to localStorage so + // a future mount can retry them; leave the flag + // unset so the migration effect re-fires next time. + persistScratchlist(sessionId, failedEntries) + migrationAttemptedRef.current = false + setMigrationStatus('idle') + return + } writeMigrationFlag(sessionId) await queryClient.invalidateQueries({ queryKey }) setMigrationStatus('completed') } catch { - // Whole-flow failure (network out, etc): leave the flag - // unset so a future mount retries; clear the banner - // status so we don't show "completed" for a half-done - // migration. + // Whole-flow failure (network out, etc): persist the + // entries that hadn't been attempted yet plus any that + // failed up to the throw, leave the flag unset, and + // clear the banner status so we don't show "completed" + // for a half-done migration. + if (failedEntries.length > 0) { + persistScratchlist(sessionId, failedEntries) + } migrationAttemptedRef.current = false setMigrationStatus('idle') } @@ -431,8 +456,17 @@ export function useHubScratchlist( // surface (e.g. the standalone `ScratchlistPanel` used by tests) // working when offline, and protects against losing freshly-added // entries if the hub goes away mid-session. + // + // CRITICAL: gate on the migration flag. Pre-migration, localStorage + // holds the v1 entries that the migration effect needs to read; if + // we mirrored an empty hub fetch into localStorage on first render + // we'd wipe the very entries we're about to upload (HAPI Bot + // review on PR #896 caught a closely-related data-loss path). The + // flag also stays unset on partial-failure migrations, which keeps + // the failed-entry localStorage write from being clobbered. useEffect(() => { if (!sessionId) return + if (!readMigrationFlag(sessionId)) return const data = query.data if (!data) return try { @@ -448,7 +482,7 @@ export function useHubScratchlist( } catch { // Non-fatal: storage quota / private mode. } - }, [sessionId, query.data]) + }, [sessionId, query.data, migrationStatus]) const entries: ScratchlistEntry[] = (query.data?.entries ?? []).map(toLocalEntry) From 390dfba774245c4481be773697b0da2ae2866b92 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:29:54 +0100 Subject: [PATCH 004/128] feat(web/scratchlist): per-entry age indicator (clock icon + tooltip) Surfaces the smart-relative time the entry was last saved on every scratchlist row, mirroring the bucketing used in the session list: just-now -> Nm -> Nh -> Nd -> absolute date. Implementation: - Extract the existing `formatRelativeTime` helper out of SessionList into `web/src/lib/relative-time.ts` so the panel can reuse the same buckets and i18n keys (no copy-paste drift between surfaces). Also add `formatAbsoluteDateTime` for the precise-stamp tooltip line. - Add `updatedAt?: number` to the local `ScratchlistEntry` shape. v1-only callers stay valid (the field is optional and `isEntry` now accepts rows that omit it). The hub hook forwards the hub's `updatedAt` so the indicator reflects edits, not just creation. - New `EntryAgeIndicator` component: clock SVG in the same style as the existing action icons, rendered inside both panel surfaces (the older `ScratchlistList` and the drawer variant). Falls back to `createdAt` when `updatedAt` is missing (legacy v1 rows during the migration window) and renders nothing if neither timestamp is usable. - Tooltip carries the relative bucket plus the absolute timestamp on a second line; aria-label carries the relative bucket only so screen readers stay terse. - Mirror `updatedAt` into the localStorage offline cache so an offline reload still has accurate ages. Tests: - `relative-time.test.ts`: bucket math, seconds-vs-ms detection, non-finite guard. - `ScratchlistPanel.test.tsx`: indicator renders with the right smart-relative bucket, falls back to `createdAt` when `updatedAt` is absent, and renders nothing when both timestamps are zero. Co-authored-by: Cursor --- .../AssistantChat/ScratchlistPanel.test.tsx | 68 ++++++++++++++++ .../AssistantChat/ScratchlistPanel.tsx | 54 +++++++++++++ web/src/components/SessionList.tsx | 17 +--- web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/lib/relative-time.test.ts | 81 +++++++++++++++++++ web/src/lib/relative-time.ts | 47 +++++++++++ web/src/lib/scratchlist.ts | 27 +++++-- web/src/lib/use-hub-scratchlist.ts | 13 +-- 9 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 web/src/lib/relative-time.test.ts create mode 100644 web/src/lib/relative-time.ts diff --git a/web/src/components/AssistantChat/ScratchlistPanel.test.tsx b/web/src/components/AssistantChat/ScratchlistPanel.test.tsx index 79cd55c6bb..47768e7c03 100644 --- a/web/src/components/AssistantChat/ScratchlistPanel.test.tsx +++ b/web/src/components/AssistantChat/ScratchlistPanel.test.tsx @@ -306,4 +306,72 @@ describe('ScratchlistPanel', () => { expect(b.getByText('B note')).toBeTruthy() expect(b.queryByText('A note')).toBeNull() }) + + it('renders the per-entry age indicator with a tooltip showing the smart-relative time', () => { + // 5 minutes ago, deterministic via fake timers below. + vi.useFakeTimers() + const now = new Date('2026-06-13T17:00:00Z').getTime() + vi.setSystemTime(new Date(now)) + try { + persistScratchlist(SID, [ + makeEntry({ + id: 'aged', + text: 'aged note', + createdAt: now - 10 * 60_000, + updatedAt: now - 5 * 60_000, + }), + ]) + renderPanel() + expandPanel() + const indicator = screen.getByTestId('scratchlist-entry-age') + // Tooltip carries the smart-relative time + an absolute + // timestamp; aria-label carries the relative time only. + const title = indicator.getAttribute('title') ?? '' + expect(title).toContain('5m ago') + expect(title).toContain('Saved') + const aria = indicator.getAttribute('aria-label') ?? '' + expect(aria).toContain('5m ago') + // data-entry-age mirrors the relative bucket so a future + // assertion can target it without scraping the title. + expect(indicator.getAttribute('data-entry-age')).toBe('5m ago') + } finally { + vi.useRealTimers() + } + }) + + it('falls back to createdAt when updatedAt is absent (legacy v1 row)', () => { + vi.useFakeTimers() + const now = new Date('2026-06-13T17:00:00Z').getTime() + vi.setSystemTime(new Date(now)) + try { + persistScratchlist(SID, [ + makeEntry({ + id: 'legacy', + text: 'legacy v1 note', + createdAt: now - 2 * 60 * 60_000, + // updatedAt deliberately omitted - simulates a + // localStorage row written by v1 before the v2 hub + // sync work added the column. + }), + ]) + renderPanel() + expandPanel() + const indicator = screen.getByTestId('scratchlist-entry-age') + expect(indicator.getAttribute('data-entry-age')).toBe('2h ago') + } finally { + vi.useRealTimers() + } + }) + + it('renders no age indicator when both timestamps are unusable', () => { + // The schema validator (`isEntry`) rejects rows with a + // non-finite `createdAt`, so the only realistic path to a + // missing-stamp entry is rendering an in-memory entry directly. + // We simulate that by writing a row with a sentinel `0` + // createdAt - the indicator returns null per its guard. + persistScratchlist(SID, [makeEntry({ id: 'no-stamp', text: 'note', createdAt: 0 })]) + renderPanel() + expandPanel() + expect(screen.queryByTestId('scratchlist-entry-age')).toBeNull() + }) }) diff --git a/web/src/components/AssistantChat/ScratchlistPanel.tsx b/web/src/components/AssistantChat/ScratchlistPanel.tsx index f684b69229..c34fca95c8 100644 --- a/web/src/components/AssistantChat/ScratchlistPanel.tsx +++ b/web/src/components/AssistantChat/ScratchlistPanel.tsx @@ -20,6 +20,7 @@ import { } from '@/lib/scratchlist' import { safeCopyToClipboard } from '@/lib/clipboard' import { useTranslation } from '@/lib/use-translation' +import { formatAbsoluteDateTime, formatRelativeTime } from '@/lib/relative-time' const STORAGE_KEY_PREFIX = 'hapi.scratchlist-collapsed.v1.' @@ -150,6 +151,57 @@ function CopyIcon() { ) } +function ClockIcon() { + return ( + + ) +} + +/** + * Per-entry age indicator: clock icon with a tooltip showing + * smart-relative time (e.g. "2m ago") and the absolute timestamp on a + * second line, so an operator can tell at-a-glance how stale a note is. + * + * Renders nothing when no usable timestamp is available - this happens + * for legacy localStorage entries that pre-date the v2 hub-sync work + * (no `updatedAt` recorded) AND have no `createdAt` either, which is + * vanishingly rare but still a guard against `NaN` titles. + * + * Falls back to `createdAt` when `updatedAt` is missing so newly-loaded + * v1-only rows still get a useful tooltip during the migration window. + */ +function EntryAgeIndicator({ + entry, +}: { + entry: ScratchlistEntry +}) { + const { t } = useTranslation() + const stamp = entry.updatedAt ?? entry.createdAt + if (!Number.isFinite(stamp) || stamp <= 0) return null + const relative = formatRelativeTime(stamp, t) + if (!relative) return null + const absolute = formatAbsoluteDateTime(stamp) + const ariaLabel = t('scratchlist.entry.lastSavedAriaLabel', { time: relative }) + const title = absolute + ? `${t('scratchlist.entry.lastSaved', { time: relative })}\n${absolute}` + : t('scratchlist.entry.lastSaved', { time: relative }) + return ( + + + + ) +} + function ClipboardCheckIcon() { return (
+ +
+ ) + } + + const { payload } = load + + return ( +
+
+
+
+
{t('share.title')}
+ +
+
+ {t('share.subtitle')} +
+
+
+ +
+
+ + +
+
+ {t('share.recentSessions')} +
+ {sessionsLoading ? ( + + ) : activeSessions.length === 0 ? ( +
+ {t('share.noActiveSessions')} +
+ ) : ( +
    + {activeSessions.map((session) => ( +
  • + +
  • + ))} +
+ )} +
+ + +
+
+
+ ) +} diff --git a/web/src/sw.ts b/web/src/sw.ts index ebe55dc0a7..c502aef440 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -3,6 +3,11 @@ import { precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst } from 'workbox-strategies' import { ExpirationPlugin } from 'workbox-expiration' +import { + cleanupExpiredShareTransfers, + ingestShareRequest, + putShareTransfer, +} from './lib/shareTransfer' declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array @@ -121,3 +126,41 @@ self.addEventListener('notificationclick', (event) => { const url = data?.url ?? '/' event.waitUntil(self.clients.openWindow(url)) }) + +// Web Share Target — manifest declares POST /share, Android Chrome posts a +// multipart form with title/text/url/files. Stash in IDB so the SPA route +// can read it after the 303 redirect (which converts POST -> GET). +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'POST') return + const url = new URL(request.url) + if (url.pathname !== '/share') return + + event.respondWith(handleShareTarget(request)) +}) + +async function handleShareTarget(request: Request): Promise { + // Resolve to absolute URLs because Response.redirect throws on relative + // input per the Fetch spec; Chrome currently tolerates relative paths + // but the SW spec is explicit and the cost of resolving is one line. + const origin = self.location.origin + try { + const { redirectTo } = await ingestShareRequest(request, { put: putShareTransfer }) + return Response.redirect(new URL(redirectTo, origin).toString(), 303) + } catch (error) { + // Surface a minimal page if IDB write fails — don't 5xx silently or + // the user gets a Chrome error sheet instead of useful UI. + console.error('share-target ingest failed', error) + return Response.redirect(new URL('/share?error=ingest', origin).toString(), 303) + } +} + +// Best-effort GC for stale share transfers (TTL-only — never blocks +// anything else). 1h TTL is set in shareTransfer.ts. +self.addEventListener('activate', (event) => { + event.waitUntil( + cleanupExpiredShareTransfers().catch((error) => { + console.warn('share-transfer cleanup failed', error) + }) + ) +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 58ab142268..d01b09dd43 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -99,7 +99,38 @@ export default defineConfig({ type: 'image/png', purpose: 'any' } - ] + ], + // Web Share Target — Android Chrome routes POSTs to /share + // when the user picks HAPI in the system share sheet. The + // service worker (`web/src/sw.ts`) intercepts POST /share, + // stashes the multipart payload in IndexedDB, and 303- + // redirects to /share?id= for the SPA picker. + // `*/*` is the broad fallback; explicit MIME prefixes stay + // first because some Chrome versions only honor declared + // prefixes when surfacing in the share sheet. + share_target: { + action: '/share', + method: 'POST', + enctype: 'multipart/form-data', + params: { + title: 'title', + text: 'text', + url: 'url', + files: [ + { + name: 'files', + accept: [ + 'image/*', + 'application/pdf', + 'text/*', + 'application/json', + 'application/zip', + '*/*' + ] + } + ] + } + } }, injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] From 370a6d2bafc3e855d88e3c4f3c60209ef65edd4f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:16:30 +0100 Subject: [PATCH 010/128] soup: renumber scratchlist migration v10->v11 for fcm-push-api stack In soup context (driver-manifest layer order), feat/companion-fcm-push-api is layer 1 and bumps SCHEMA_VERSION 9->10 with fcm_devices. v2 was developed against upstream/main where SCHEMA_VERSION=9 and bumped to 10 with session_scratchlist. Two layers writing the same v10 collide. This branch (used as a soup-only layer; do NOT submit upstream as PR; operator-fork PR #896 stays at v9->v10) renumbers scratchlist's bump to v10->v11: - SCHEMA_VERSION: 10 -> 11 - buildStepMigrations: stepMigrations[9] -> stepMigrations[10] - migrateFromV9ToV10 -> migrateFromV10ToV11 - migration-v10.test.ts -> migration-v11.test.ts (with V*->V** copy updates) Note: this branch is NOT standalone-testable. The migration-ladder gap at stepMigrations[9] exists because this branch alone does not include feat/companion-fcm-push-api's fcm_devices migration. Tests pass only inside the soup integration tree where both layers are merged together. Co-authored-by: Cursor --- hub/src/store/index.ts | 6 ++-- ...tion-v10.test.ts => migration-v11.test.ts} | 29 ++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) rename hub/src/store/{migration-v10.test.ts => migration-v11.test.ts} (93%) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index af6e923f47..cfe18f7512 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -26,7 +26,7 @@ export { ScratchlistStore } from './scratchlistStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 10 +const SCHEMA_VERSION: number = 11 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -129,7 +129,7 @@ export class Store { 6: () => this.migrateFromV6ToV7(), 7: () => this.migrateFromV7ToV8(), 8: () => this.migrateFromV8ToV9(), - 9: () => this.migrateFromV9ToV10(), + 10: () => this.migrateFromV10ToV11(), }) if (currentVersion === 0) { @@ -463,7 +463,7 @@ export class Store { * v2 hub-side entries (web client retains its localStorage offline * cache). */ - private migrateFromV9ToV10(): void { + private migrateFromV10ToV11(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS session_scratchlist ( session_id TEXT NOT NULL, diff --git a/hub/src/store/migration-v10.test.ts b/hub/src/store/migration-v11.test.ts similarity index 93% rename from hub/src/store/migration-v10.test.ts rename to hub/src/store/migration-v11.test.ts index daed5a5633..640ef5db03 100644 --- a/hub/src/store/migration-v10.test.ts +++ b/hub/src/store/migration-v11.test.ts @@ -6,20 +6,22 @@ import { tmpdir } from 'node:os' import { Store } from './index' /** - * Tests for V9→V10 schema migration: introduces the `session_scratchlist` - * typed table for tiann/hapi#893 (scratchlist v2 hub sync). Mirrors the - * pattern in `migration-v9.test.ts`. + * Tests for V10→V11 schema migration: introduces the `session_scratchlist` + * typed table for tiann/hapi#893 (scratchlist v2 hub sync). Renumbered + * v10→v11 for soup integration where v10 is the fcm-push-api schema bump. + * Mirrors the pattern in `migration-v9.test.ts` (and `migration-v10.test.ts` + * for fcm). * * The new table is independent (no backfill from existing rows) so the * tests focus on: * - presence on fresh DBs - * - presence on multi-hop legacy DBs (V6/V7/V8/V9 → V10) + * - presence on multi-hop legacy DBs (V6/V7/V8/V9/V10 → V11) * - idempotency on reopen * - foreign-key cascade-delete from sessions(id) * - the supporting (session_id, created_at) index * - basic CRUD operations through the ScratchlistStore wrapper */ -describe('Store V9→V10 migration: session_scratchlist table', () => { +describe('Store V10→V11 migration: session_scratchlist table', () => { it('fresh DB has session_scratchlist table with expected columns', () => { const store = new Store(':memory:') const cols = getColumns(store, 'session_scratchlist') @@ -39,8 +41,8 @@ describe('Store V9→V10 migration: session_scratchlist table', () => { expect(rows).toHaveLength(1) }) - it('V9 DB migrates to V10 via Store: session_scratchlist created', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-test-')) + it('V9 DB upgrades through ladder to V11: session_scratchlist created', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-test-')) const dbPath = join(dir, 'test.db') let store: Store | undefined try { @@ -69,8 +71,8 @@ describe('Store V9→V10 migration: session_scratchlist table', () => { } }) - it('V8 DB migrates to V10 (multi-hop V8→V9→V10)', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v8-to-v10-')) + it('V8 DB migrates to V11 (multi-hop V8→V9→V10→V11)', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v8-to-v11-')) const dbPath = join(dir, 'test.db') let store: Store | undefined try { @@ -82,7 +84,7 @@ describe('Store V9→V10 migration: session_scratchlist table', () => { db.close() store = new Store(dbPath) - // After ladder runs, both v8→v9 (scheduled_at) AND v9→v10 (table) applied. + // After ladder runs, v8→v9 (scheduled_at) and v10→v11 (scratchlist table) applied. const messageCols = getColumns(store, 'messages') expect(messageCols).toContain('scheduled_at') const scratchCols = getColumns(store, 'session_scratchlist') @@ -93,8 +95,8 @@ describe('Store V9→V10 migration: session_scratchlist table', () => { } }) - it('V10 DB reopen is idempotent: schema unchanged', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v10-idempotent-')) + it('V11 DB reopen is idempotent: schema unchanged', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v11-idempotent-')) const dbPath = join(dir, 'test.db') let store1: Store | undefined let store2: Store | undefined @@ -241,7 +243,8 @@ function getColumns(store: Store, table: string): string[] { /** * V9 schema: full pre-V10 shape. Used to seed legacy DBs to verify the - * V9→V10 step adds the new table without disturbing existing rows. + * full migration ladder (V9 → V10 fcm-devices → V11 session_scratchlist) + * preserves rows and adds tables without disturbing existing data. */ function createV9Schema(db: Database): void { db.exec(` From d64cb027b148d07fc03963e1c7a472559783fe52 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:35:29 +0100 Subject: [PATCH 011/128] feat(runner): adopt orphaned children on late webhook instead of killing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with PR #928 (KillMode=process). When the runner systemd unit uses KillMode=process, agent children survive runner restart - they're spawned `detached: true` (cli/src/runner/run.ts:454) so they keep running after the runner exits. On runner cold start they eventually emit a heartbeat / spawn-complete webhook reporting their sessionId and PID. Before this change the runner saw "no tracked session for this PID" and killed the orphan as defense-in-depth against same-runner-instance ghost sessions. That defense was correct when the only orphan source was a timed-out spawn whose tree-kill had already fired; it's wrong when the orphan source is "different runner lifetime, KillMode=process let the child survive". Differentiate via process liveness: - Dead PID → case 2 (timeout already handled it). Log + no-op. - Alive PID → case 3 (post-restart survivor). Adopt: build a TrackedSession entry without childProcess and store it in pidToTrackedSession. Subsequent webhooks update normally; stopSession (line 659) falls into the no-childProcess branch and uses killProcess(pid). Closes the #929 (Tier B) re-attach scenario for runner-only restarts where children survive. The hub-down / full-reboot scenario where children are dead is intentionally out of scope for this iteration - that needs un-archive + respawn-with-resume which is a bigger semantic change. Soup-only for now; upstream PR held until #928 lands. Note on PID recycling: this implementation trusts the webhook's self-reported sessionId. PID recycling between runner restart and webhook delivery would be a low-probability collision; v2 should cross-check via process start-time or cmdline pattern. AI-disclosure (CONTRIBUTING.md): drafted with claude-opus-4.7 in the backup-agent role during a fork-side post-mortem of the cascade outage chain that led to PRs #928 and #929. Co-authored-by: Cursor --- cli/src/runner/run.ts | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index f49d3b15b1..aac5b7c4a8 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -243,28 +243,39 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): logger.debug(`[RUNNER RUN] Resolved session awaiter for PID ${pid}`); } } else if (!existingSession) { - // No tracked session for this PID. Two possibilities: + // No tracked session for this PID. Three possibilities: // 1. The child was spawned externally from a terminal (legitimate). // 2. The child was runner-spawned but already had its tracking // entry removed because its webhook arrived after the timeout - // (orphaned / ghost-session case). + // (orphaned / same-runner-instance ghost-session case). The + // timeout handler should have already tree-killed the PID, so + // the process is dead by now. + // 3. The child was runner-spawned by a PRIOR runner instance and + // survived a runner restart because the runner systemd unit + // uses `KillMode=process` (per docs/guide/installation.md + // installer; closes #915 / closes #929). The PID is alive, + // legitimately wants to be re-attached. // - // Differentiate via the webhook's own `startedBy` field: genuine - // terminal-launched children report `startedBy: 'terminal'`, so - // anything claiming `'runner'` here must be the second case and - // should be ignored + terminated instead of silently promoted. + // Differentiate cases 2 and 3 via process liveness: dead PID is + // case 2 (no-op, the timeout already handled it); alive PID is + // case 3 (adopt and re-attach). if (sessionMetadata.startedBy === 'runner') { + if (isProcessAlive(pid)) { + const adoptedSession: TrackedSession = { + startedBy: 'runner', + happySessionId: sessionId, + happySessionMetadataFromLocalWebhook: sessionMetadata, + pid + }; + pidToTrackedSession.set(pid, adoptedSession); + logger.debug( + `[RUNNER RUN] Adopting orphaned runner-spawned session ${sessionId} (PID ${pid}). Likely a survivor of a prior runner restart.` + ); + return; + } logger.debug( - `[RUNNER RUN] Ignoring late webhook from orphaned runner-spawned PID ${pid} (session ${sessionId}). Terminating child.` + `[RUNNER RUN] Ignoring late webhook from dead orphaned runner-spawned PID ${pid} (session ${sessionId}). Process already gone.` ); - // Use killProcess (SIGTERM → SIGKILL escalation) rather than a - // bare process.kill() so the orphan is reliably reaped even if - // it ignores SIGTERM. We don't have a ChildProcess reference - // here (tracking entry was already removed by the timeout - // handler), so tree-kill via killProcessByChildProcess is not - // available — but the timeout handler should have already - // tree-killed the process group; this is defence-in-depth. - void killProcess(pid); return; } From 7b11ee8e46de162fde0990de8bf7d5597b84322a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:44:50 +0100 Subject: [PATCH 012/128] fix(web/share): freeze picker session list at mount, sort by updatedAt, drop top-5 cap The /share picker was visually re-shuffling under the operator's finger as SSE events rolled in: every session metadata patch refreshed the React Query cache, the useMemo recomputed, and items reordered (often within a second of opening the share sheet). Sort key was activeAt, which heartbeats every few seconds while a session is connected, making the noise floor even higher. Three changes: - Snapshot the active-session list once when sessions finish loading via useState + a deferred useEffect. The picker is a one-shot interaction; closing the share sheet and re-sharing produces a fresh snapshot, so freezing for the duration of the picker view is the right trade. - Sort by updatedAt desc to match SessionList's canonical "most recent interaction first" order. updatedAt only moves on user-meaningful events, not heartbeats. - Drop the TOP_SESSIONS=5 cap. The picker is already inside an app-scroll-y container, so showing all active sessions and letting the operator scroll matches the operator's mental model better than an arbitrary truncation. Per operator dogfood report: "list of recent sessions is constantly updating; should be just a scrollable list, from most recent interaction to not." Co-authored-by: Cursor --- web/src/routes/share/index.tsx | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/web/src/routes/share/index.tsx b/web/src/routes/share/index.tsx index 865db47271..0ec28b2f5b 100644 --- a/web/src/routes/share/index.tsx +++ b/web/src/routes/share/index.tsx @@ -12,8 +12,6 @@ import { import { setSharePendingTransfer } from '@/lib/sharePendingState' import type { SessionSummary } from '@/types/api' -const TOP_SESSIONS = 5 - type LoadState = | { state: 'loading' } | { state: 'missing'; reason: 'not-found' | 'ingest-error' | 'no-id' } @@ -143,12 +141,24 @@ export default function SharePage() { return () => { cancelled = true } }, [transferId, ingestError]) - const activeSessions = useMemo(() => { - return [...sessions] - .filter((s) => s.active) - .sort((a, b) => b.activeAt - a.activeAt) - .slice(0, TOP_SESSIONS) - }, [sessions]) + // Snapshot the active session list once when sessions finish loading so + // the picker doesn't re-shuffle under the operator's finger as SSE + // updates roll in (activeAt heartbeats nudge the order every few + // seconds; even updatedAt-keyed sorts visually flicker on every + // metadata patch). The picker is a one-shot interaction — closing the + // share sheet and re-sharing produces a fresh snapshot. Sorted by + // updatedAt desc to match SessionList's canonical "most recent + // interaction first" order. + const [pickerSessions, setPickerSessions] = useState(null) + useEffect(() => { + if (pickerSessions !== null) return + if (sessionsLoading) return + setPickerSessions( + [...sessions] + .filter((s) => s.active) + .sort((a, b) => b.updatedAt - a.updatedAt) + ) + }, [pickerSessions, sessions, sessionsLoading]) const handlePickSession = useCallback((sessionId: string) => { if (!transferId) return @@ -235,15 +245,15 @@ export default function SharePage() {
{t('share.recentSessions')}
- {sessionsLoading ? ( + {pickerSessions === null ? ( - ) : activeSessions.length === 0 ? ( + ) : pickerSessions.length === 0 ? (
{t('share.noActiveSessions')}
) : (
    - {activeSessions.map((session) => ( + {pickerSessions.map((session) => (
  • +
+ + + + ) +} diff --git a/web/src/garden/GardenScene.tsx b/web/src/garden/GardenScene.tsx new file mode 100644 index 0000000000..eaf4eb053e --- /dev/null +++ b/web/src/garden/GardenScene.tsx @@ -0,0 +1,118 @@ +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Canvas, useThree } from '@react-three/fiber' +import { Stars } from '@react-three/drei' +import { XROrigin, XR, createXRStore, useXR } from '@react-three/xr' +import * as THREE from 'three' +import { useAppContext } from '@/lib/app-context' +import { useSessions } from '@/hooks/queries/useSessions' +import { AgentOrb } from '@/garden/components/AgentOrb' +import { ExitPad } from '@/garden/components/ExitPad' +import { GardenHeadRaycaster, type GardenGazeTarget } from '@/garden/components/GardenHeadRaycaster' +import { GardenXrPresentingSync } from '@/garden/components/GardenXrPresentingSync' +import { useGardenFocus } from '@/garden/context/GardenRuntimeContext' +import { useGardenAttention } from '@/garden/hooks/useGardenAttention' +import { useGardenSpatialAudio } from '@/garden/hooks/useGardenSpatialAudio' +import { filterGardenSessions, GARDEN_BUILD, layoutPosition } from '@/garden/utils/sessionVisuals' + +export const xrStore = createXRStore({ + gaze: false, + hand: false, + controller: false, + screenInput: true, +}) + +function GardenWorld() { + const { api } = useAppContext() + const { camera } = useThree() + const isPresenting = useXR((state) => state.session !== undefined) + const { sessions } = useSessions(api) + const visible = useMemo(() => filterGardenSessions(sessions), [sessions]) + const { focusedId, setFocusedId } = useGardenFocus() + const { attentionIds, attentionCueTokens } = useGardenAttention(visible, focusedId) + const [gazeTarget, setGazeTarget] = useState(null) + + const orbHitTargets = useRef(new Map()) + const exitHitTarget = useRef(null) + + const playSpatialCue = useGardenSpatialAudio(isPresenting) + + const playCueSound = useCallback((worldPosition: THREE.Vector3) => { + playSpatialCue(worldPosition, camera) + }, [camera, playSpatialCue]) + + const registerOrbHitTarget = useCallback((sessionId: string, mesh: THREE.Mesh | null) => { + if (mesh) { + orbHitTargets.current.set(sessionId, mesh) + return + } + orbHitTargets.current.delete(sessionId) + }, []) + + const registerExitHitTarget = useCallback((mesh: THREE.Mesh | null) => { + exitHitTarget.current = mesh + }, []) + + const gazeSessionId = gazeTarget?.kind === 'orb' ? gazeTarget.sessionId : null + const gazeExitPad = gazeTarget?.kind === 'exit' + + useEffect(() => { + if (focusedId && !visible.some((session) => session.id === focusedId)) { + setFocusedId(null) + } + }, [focusedId, setFocusedId, visible]) + + return ( + <> + + + + + + + + + + + + + + + {visible.map((session, index) => ( + + + + ))} + + + ) +} + +export function GardenScene() { + return ( + + + + + + + + ) +} + +export { GARDEN_BUILD } diff --git a/web/src/garden/GardenVoiceBridge.tsx b/web/src/garden/GardenVoiceBridge.tsx new file mode 100644 index 0000000000..6a38572ace --- /dev/null +++ b/web/src/garden/GardenVoiceBridge.tsx @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useAppContext } from '@/lib/app-context' +import { useVoiceOptional } from '@/lib/voice-context' +import { makeClientSideId } from '@/lib/messages' +import { useSessions } from '@/hooks/queries/useSessions' +import { useSession } from '@/hooks/queries/useSession' +import { useMessages } from '@/hooks/queries/useMessages' +import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' +import { useGardenRuntime } from '@/garden/context/GardenRuntimeContext' +import { filterGardenSessions } from '@/garden/utils/sessionVisuals' +import { applyPersonaOverride } from '@/garden/utils/voicePersona' +import { fetchVoiceBackend } from '@/api/voice' +import type { VoiceBackendType } from '@hapi/protocol/voice' +import type { DecryptedMessage } from '@/types/api' + +export function GardenVoiceBridge() { + const { api } = useAppContext() + const voice = useVoiceOptional() + const queryClient = useQueryClient() + const { focusedId, isPresenting } = useGardenRuntime() + const { sessions } = useSessions(api) + const visible = filterGardenSessions(sessions) + const { session: focusedSession, refetch: refetchSession } = useSession(api, focusedId) + const { messages: focusedMessages } = useMessages(api, focusedId) + const [backend, setBackend] = useState(null) + + useEffect(() => { + if (!api) { + return + } + let cancelled = false + fetchVoiceBackend(api) + .then((resp) => { + if (!cancelled) { + setBackend(resp.backend) + } + }) + .catch(() => { + if (!cancelled) { + setBackend(null) + } + }) + return () => { + cancelled = true + } + }, [api]) + + const startingRef = useRef(false) + const autoVoiceRef = useRef(false) + const prevMessagesRef = useRef([]) + const prevThinkingRef = useRef(undefined) + const prevRequestIdsRef = useRef>(new Set()) + + const refreshSession = useCallback(async () => { + await refetchSession() + if (focusedId) { + await queryClient.invalidateQueries({ queryKey: ['sessions'] }) + } + }, [focusedId, queryClient, refetchSession]) + + useEffect(() => { + if (!api || !focusedId || !focusedSession) { + registerSessionStore(null) + return + } + + registerSessionStore({ + getSession: (sessionId: string) => ( + sessionId === focusedId && focusedSession + ? focusedSession as { agentState?: { requests?: Record } } + : null + ), + sendMessage: (_sessionId: string, message: string) => { + const localId = makeClientSideId('local') + void api.sendMessage(focusedId, message, localId) + }, + approvePermission: async (_sessionId: string, requestId: string) => { + await api.approvePermission(focusedId, requestId) + await refreshSession() + }, + denyPermission: async (_sessionId: string, requestId: string) => { + await api.denyPermission(focusedId, requestId) + await refreshSession() + }, + }) + }, [api, focusedId, focusedSession, refreshSession]) + + useEffect(() => { + registerVoiceHooksStore( + (sessionId) => (sessionId === focusedId && focusedSession ? focusedSession : null), + (sessionId) => (sessionId === focusedId ? focusedMessages : []), + ) + }, [focusedId, focusedSession, focusedMessages]) + + useEffect(() => { + if (!focusedId || !focusedSession) { + prevMessagesRef.current = [] + return + } + + const prevIds = new Set(prevMessagesRef.current.map((message) => message.id)) + const newMessages = focusedMessages.filter((message) => !prevIds.has(message.id)) + + if (newMessages.length > 0) { + voiceHooks.onMessages(focusedId, newMessages) + } + + prevMessagesRef.current = focusedMessages + }, [focusedId, focusedMessages, focusedSession]) + + useEffect(() => { + if (!focusedId || !focusedSession) { + prevThinkingRef.current = undefined + return + } + + if (prevThinkingRef.current === true && !focusedSession.thinking) { + voiceHooks.onReady(focusedId) + } + + prevThinkingRef.current = focusedSession.thinking + }, [focusedId, focusedSession]) + + useEffect(() => { + if (!focusedId || !focusedSession) { + prevRequestIdsRef.current = new Set() + return + } + + const requests = focusedSession.agentState?.requests ?? {} + const currentIds = new Set(Object.keys(requests)) + + for (const [requestId, request] of Object.entries(requests)) { + if (!prevRequestIdsRef.current.has(requestId)) { + voiceHooks.onPermissionRequested( + focusedId, + requestId, + (request as { tool?: string }).tool ?? 'unknown', + (request as { arguments?: unknown }).arguments, + ) + } + } + + prevRequestIdsRef.current = currentIds + }, [focusedId, focusedSession]) + + const startVoiceForFocus = useCallback(async (sessionId: string) => { + if (!voice || startingRef.current) { + return + } + + startingRef.current = true + // Per-orb voice persona: stamp the persona id into the active backend's + // localStorage key just before startSession reads it. Restore the operator's + // global pick afterward so flat HAPI behaviour is unchanged. + const personaOverride = backend + ? applyPersonaOverride(sessionId, backend) + : { persona: null, restore: () => {} } + try { + const summary = visible.find((session) => session.id === sessionId) + if (summary) { + voiceHooks.onSessionFocus(sessionId, summary.metadata ?? undefined) + } + + if (voice.currentSessionId && voice.currentSessionId !== sessionId) { + await voice.stopVoice() + } + if (voice.currentSessionId !== sessionId) { + await voice.startVoice(sessionId) + } + } catch (error) { + console.error('[Garden Voice] Failed to start voice:', error) + } finally { + personaOverride.restore() + startingRef.current = false + } + }, [voice, visible, backend]) + + useEffect(() => { + if (!voice) { + return + } + + if (!focusedId) { + if (autoVoiceRef.current && voice.currentSessionId) { + autoVoiceRef.current = false + void voice.stopVoice() + } + return + } + + if (isPresenting) { + autoVoiceRef.current = true + void startVoiceForFocus(focusedId) + return + } + + if (autoVoiceRef.current && voice.currentSessionId) { + autoVoiceRef.current = false + void voice.stopVoice() + } + }, [focusedId, isPresenting, voice, startVoiceForFocus]) + + if (!voice) { + return null + } + + return ( + + ) +} diff --git a/web/src/garden/components/AgentOrb.tsx b/web/src/garden/components/AgentOrb.tsx new file mode 100644 index 0000000000..b862e2e24f --- /dev/null +++ b/web/src/garden/components/AgentOrb.tsx @@ -0,0 +1,218 @@ +import { useEffect, useRef, useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { Text } from '@react-three/drei' +import { useXR } from '@react-three/xr' +import * as THREE from 'three' +import { useQuery } from '@tanstack/react-query' +import { useAppContext } from '@/lib/app-context' +import { useVoiceOptional } from '@/lib/voice-context' +import { extractLastMessageText } from '@/garden/utils/messageText' +import { GardenLiveWebPanel } from '@/garden/components/GardenLiveWebPanel' +import { GardenYawBillboard } from '@/garden/components/GardenYawBillboard' +import { OrbPlatonicBody } from '@/garden/components/OrbPlatonicBody' +import { VoiceReactiveRings } from '@/garden/components/VoiceReactiveRings' +import { + ATTENTION_COLOR, + DWELL_SECONDS, + ORB_LABEL_POSITION, + SNIPPET_COMPACT_POSITION, + SNIPPET_FOCUS_POSITION, + sessionColor, + sessionLabel, +} from '@/garden/utils/sessionVisuals' +import { resolveOrbShapeKind } from '@/garden/utils/orbShapes' +import type { SessionSummary } from '@/types/api' + +type AgentOrbProps = { + session: SessionSummary + attention: boolean + attentionCueToken: number + focused: boolean + gazeTargeted: boolean + onFocus: (sessionId: string) => void + onCueSound: (worldPosition: THREE.Vector3) => void + onHitTarget: (sessionId: string, mesh: THREE.Mesh | null) => void +} + +export function AgentOrb(props: AgentOrbProps) { + const { + session, + attention, + attentionCueToken, + focused, + gazeTargeted, + onFocus, + onCueSound, + onHitTarget, + } = props + const voice = useVoiceOptional() + const isPresenting = useXR((state) => state.session !== undefined) + const voiceLinked = voice?.currentSessionId === session.id && voice.status === 'connected' + const shapeKind = resolveOrbShapeKind(session, attention) + const groupRef = useRef(null) + const hitRef = useRef(null) + const [pointerHovered, setPointerHovered] = useState(false) + const [dwell, setDwell] = useState(0) + const targeted = pointerHovered || gazeTargeted + const baseColor = sessionColor(session) + const displayColor = useRef(new THREE.Color(baseColor)) + const targetColor = useRef(new THREE.Color(attention ? ATTENTION_COLOR : baseColor)) + const lastCueTokenRef = useRef(0) + + useEffect(() => { + onHitTarget(session.id, hitRef.current) + return () => onHitTarget(session.id, null) + }, [onHitTarget, session.id]) + + useEffect(() => { + targetColor.current.set(attention ? ATTENTION_COLOR : baseColor) + }, [attention, baseColor]) + + useEffect(() => { + if (!attention || attentionCueToken <= lastCueTokenRef.current || !groupRef.current) { + return + } + lastCueTokenRef.current = attentionCueToken + const world = new THREE.Vector3() + groupRef.current.getWorldPosition(world) + onCueSound(world) + }, [attention, attentionCueToken, onCueSound]) + + useFrame((_, delta) => { + if (targeted && !focused) { + setDwell((value) => Math.min(1, value + delta / DWELL_SECONDS)) + } else if (!focused) { + setDwell(0) + } + + displayColor.current.lerp(targetColor.current, Math.min(1, delta * 3)) + }) + + useEffect(() => { + if (dwell >= 1 && targeted) { + onFocus(session.id) + } + }, [dwell, targeted, onFocus, session.id]) + + const showLiveWeb = focused && !isPresenting + + return ( + + { + e.stopPropagation() + setPointerHovered(true) + }} + onPointerOut={(e) => { + e.stopPropagation() + setPointerHovered(false) + }} + > + + + + {voiceLinked && } + + {attention && ( + + + + + )} + + {targeted && !focused && ( + + + + + )} + + + + {!focused && ( + + + {sessionLabel(session)} + + + )} + + {!showLiveWeb && } + + {showLiveWeb && } + + ) +} + +function sessionStatus(session: SessionSummary): string { + if (session.pendingRequestKinds.length > 0) { + return 'needs you' + } + if (session.thinking) { + return 'working' + } + if (session.active) { + return 'live' + } + return 'idle' +} + +function useOrbPreview(sessionId: string) { + const { api } = useAppContext() + return useQuery({ + queryKey: ['garden', 'preview', sessionId], + queryFn: async () => { + const res = await api.getMessages(sessionId, { limit: 24 }) + return extractLastMessageText(res.messages) + }, + staleTime: 4000, + refetchInterval: 8000, + }) +} + +function OrbSnippet(props: { session: SessionSummary; compact: boolean }) { + const { data: preview, isLoading, isError } = useOrbPreview(props.session.id) + const status = sessionStatus(props.session) + const body = isError + ? '(could not load messages)' + : isLoading + ? '…' + : preview ?? '(no speakable messages yet)' + + const position = props.compact ? SNIPPET_COMPACT_POSITION : SNIPPET_FOCUS_POSITION + const fontSize = props.compact ? 0.08 : 0.11 + const maxWidth = props.compact ? 1.8 : 2.5 + const limit = props.compact ? 96 : 420 + + return ( + + {!props.compact && ( + + + + + )} + + {props.compact + ? `[${status}] ${body.slice(0, limit)}` + : `${sessionLabel(props.session)}\nstatus: ${status}\n---\n${body.slice(0, limit)}`} + + + ) +} diff --git a/web/src/garden/components/ExitPad.tsx b/web/src/garden/components/ExitPad.tsx new file mode 100644 index 0000000000..c77392a156 --- /dev/null +++ b/web/src/garden/components/ExitPad.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { Text } from '@react-three/drei' +import * as THREE from 'three' +import { useXRStore } from '@react-three/xr' + +const EXIT_HOLD_SECONDS = 2.5 + +type ExitPadProps = { + gazeTargeted: boolean + onHitTarget: (mesh: THREE.Mesh | null) => void +} + +export function ExitPad(props: ExitPadProps) { + const { gazeTargeted, onHitTarget } = props + const store = useXRStore() + const hitRef = useRef(null) + const [pointerHovered, setPointerHovered] = useState(false) + const [progress, setProgress] = useState(0) + const progressRef = useRef(0) + const triggeredRef = useRef(false) + const targeted = pointerHovered || gazeTargeted + + useEffect(() => { + onHitTarget(hitRef.current) + return () => onHitTarget(null) + }, [onHitTarget]) + + useFrame((_, delta) => { + if (targeted && !triggeredRef.current) { + const next = Math.min(1, progressRef.current + delta / EXIT_HOLD_SECONDS) + progressRef.current = next + setProgress(next) + if (next >= 1) { + triggeredRef.current = true + void store.getState().session?.end() + } + } else if (!targeted) { + progressRef.current = 0 + setProgress(0) + triggeredRef.current = false + } + }) + + return ( + + { + e.stopPropagation() + setPointerHovered(true) + }} + onPointerOut={() => setPointerHovered(false)} + > + + + + + + + + + + {progress > 0 && ( + + + + + )} + + + {progress > 0 ? `Exit VR ${Math.ceil((1 - progress) * EXIT_HOLD_SECONDS)}s` : 'Look down to exit VR'} + + + ) +} diff --git a/web/src/garden/components/GardenHeadRaycaster.tsx b/web/src/garden/components/GardenHeadRaycaster.tsx new file mode 100644 index 0000000000..15991ddb8a --- /dev/null +++ b/web/src/garden/components/GardenHeadRaycaster.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, type RefObject } from 'react' +import { useFrame, useThree } from '@react-three/fiber' +import { useXR } from '@react-three/xr' +import * as THREE from 'three' + +export type GardenGazeTarget = + | { kind: 'orb'; sessionId: string } + | { kind: 'exit' } + | null + +type GardenHeadRaycasterProps = { + orbHitTargetsRef: React.RefObject> + exitHitTargetRef: React.RefObject + onGazeTarget: (target: GardenGazeTarget) => void +} + +function hasActiveControllers(inputSourceStates: ReadonlyArray<{ type: string }>): boolean { + return inputSourceStates.some((state) => state.type === 'controller') +} + +/** + * 3DOF head-forward gaze ray. Disabled when XR controllers are active (future: pointer fallback). + */ +export function GardenHeadRaycaster(props: GardenHeadRaycasterProps) { + const { orbHitTargetsRef, exitHitTargetRef, onGazeTarget } = props + const { camera } = useThree() + const isPresenting = useXR((state) => state.session !== undefined) + const inputSourceStates = useXR((state) => state.inputSourceStates) + const raycaster = useRef(new THREE.Raycaster()) + const direction = useRef(new THREE.Vector3()) + const lastTarget = useRef(undefined) + + useEffect(() => { + if (!isPresenting) { + lastTarget.current = null + onGazeTarget(null) + } + }, [isPresenting, onGazeTarget]) + + useFrame(() => { + if (!isPresenting || hasActiveControllers(inputSourceStates)) { + if (lastTarget.current !== null) { + lastTarget.current = null + onGazeTarget(null) + } + return + } + + camera.getWorldDirection(direction.current) + raycaster.current.set(camera.position, direction.current) + + const orbHitTargets = orbHitTargetsRef.current + const exitHitTarget = exitHitTargetRef.current + const orbMeshes = orbHitTargets ? [...orbHitTargets.values()].filter(Boolean) : [] + const candidates: THREE.Object3D[] = [...orbMeshes] + if (exitHitTarget) { + candidates.push(exitHitTarget) + } + + if (candidates.length === 0) { + if (lastTarget.current !== null) { + lastTarget.current = null + onGazeTarget(null) + } + return + } + + const hits = raycaster.current.intersectObjects(candidates, false) + const hit = hits[0]?.object ?? null + + let next: GardenGazeTarget = null + if (hit) { + const sessionId = hit.userData.gardenSessionId as string | undefined + if (sessionId) { + next = { kind: 'orb', sessionId } + } else if (hit.userData.gardenExitPad) { + next = { kind: 'exit' } + } + } + + const prev = lastTarget.current + const changed = + prev?.kind !== next?.kind + || (prev?.kind === 'orb' && next?.kind === 'orb' && prev.sessionId !== next.sessionId) + + if (changed) { + lastTarget.current = next + onGazeTarget(next) + } + }) + + return null +} diff --git a/web/src/garden/components/GardenLiveWebPanel.tsx b/web/src/garden/components/GardenLiveWebPanel.tsx new file mode 100644 index 0000000000..db77f76473 --- /dev/null +++ b/web/src/garden/components/GardenLiveWebPanel.tsx @@ -0,0 +1,43 @@ +import { Html } from '@react-three/drei' +import { useXR } from '@react-three/xr' +import { LIVE_WEB_PANEL_POSITION } from '@/garden/utils/sessionVisuals' + +type GardenLiveWebPanelProps = { + sessionId: string +} + +/** + * Real HAPI session chat in 3D space (flat browser only). + * Quest immersive mode cannot host interactive DOM/iframes — VR falls back to text panels. + */ +export function GardenLiveWebPanel(props: GardenLiveWebPanelProps) { + const isPresenting = useXR((state) => state.session !== undefined) + + if (isPresenting) { + return null + } + + const src = `/sessions/${encodeURIComponent(props.sessionId)}` + + return ( + +
+