diff --git a/AGENTS.md b/AGENTS.md index eb23938fb..1322cf5e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere. - Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions. - Persistent command/search utility windows use `components/dismissible-window.tsx`: non-modal, no scrim, outside interactions keep them open, and explicit toggles use `.icon-toggle` with `aria-expanded`/`aria-controls`. Keep search state scoped to its instance/session and close it when that view becomes inactive. -- Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store; explicit result previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics. +- Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store. Current-session results and the global timeline navigate through bounded anchor windows; other-session previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics. - Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints. - Session rows keep actions inline until their measured title, badges, and controls no longer fit. Keep responsive action styles in `styles/components/session-row-actions.css`; hidden inline controls remain measurable but inert, and an open overflow menu stays mounted until dismissal. - Session hierarchy geometry lives in `styles/components/session-tree.css`; connector axes follow the parent expander at every depth, including selection mode, RTL and touch layouts. @@ -80,7 +80,7 @@ Behavior for agents: - Use the `write` tool only when creating new files from scratch. - Browser rendering regressions live in `packages/ui/tests/browser/`, with deterministic HTTP fixtures beside them in `fixtures/`. Exercise the real Solid components and native event dispatcher rather than reimplementing rendering logic. - Transcript rows retain pointer hit testing during virtualizer scrolling (`styles/messaging/virtual-follow-list.css`), so nested code/tool scrollers and message controls receive gestures at their visible target. -- Keep the global scrollbar-width default at zero specificity (`:where(...)` in `src/index.css`) so component visibility rules win. Only the timeline rail uses `none`; the message transcript uses the shared thin scrollbar and nested code/tool outputs retain their own scrollbars. Validate with the full app stylesheet loaded. +- Keep the global scrollbar-width default at zero specificity (`:where(...)` in `src/index.css`) so component visibility rules win. The timeline reserves a discreet stable gutter in `styles/messaging/timeline-scrollbar.css`: only marker rectangles narrow, icons and vertical spacing never shrink. The message transcript uses the shared thin scrollbar and nested code/tool outputs retain their own scrollbars. Validate with the full app stylesheet loaded. - Run them with `npm run test:browser --workspace @codenomad/ui` after `npx playwright install chromium`. `CODENOMAD_BROWSER_PATH` optionally selects an existing Chromium executable; it does not target the installed application or user sessions. ## V2 Runtime Launch diff --git a/dev-docs/SESSION_HISTORY_NAVIGATION.md b/dev-docs/SESSION_HISTORY_NAVIGATION.md new file mode 100644 index 000000000..35a2bf799 --- /dev/null +++ b/dev-docs/SESSION_HISTORY_NAVIGATION.md @@ -0,0 +1,69 @@ +# Global timeline and bounded navigation + +The history-query plugin also exposes two read-only operations through dedicated +CodeNomad routes: `session-history/outline` and `session-history/window`. They use +the same authenticated native RPC registration and daemon-storage identity +challenge as history queries. They do not expose generic RPC or SQL access. + +## Read contract + +- An outline page returns at most 256 lightweight message entries, with native + sequence order, a short preview and technical-part counts. Its sequence horizon + excludes new appended messages until the next refresh. The UI publishes a + complete metadata snapshot; message payloads do not enter the transcript store. +- A window targets `around`, `before`, `after`, `oldest` or `latest`. It reads at + most 200 messages ordered by native `seq`, without traversing intervening pages. + Neighbor windows overlap by 16 messages so the reading anchor can survive a + boundary crossing. An around window includes up to 80 predecessors and 120 + messages starting at the target. +- Windows are reconstructed from native storage within a read transaction. The + isolated OpenCode fixture compares their complete payloads with native export + and single-message reads. Budgets are 16 MiB per message and 24 MiB per window; + oversized windows fail explicitly instead of silently omitting content. +- Ownership includes directory, project and legacy workspace identity. Staged + undo hides the same message-ID tail as the transcript. Missing/deleted targets + return a conflict; the broker rechecks location, project, revert and workspace + ownership before publishing either result. + +## UI navigation + +`history-window.ts` translates this contract into explicitly namespaced CodeNomad +window cursors. These are never forwarded to native `message.list`. Existing +native page cursors remain supported. A truncated newer-page path switches to a +direct resident-message anchor rather than replaying the path from the latest page. + +Timeline clicks, current-session search hits and missing restoration anchors share +`loadMessageAnchor`. Window publication retains the existing request epoch, +connection generation, mutation revision and cancellation checks. Latest-window +reads retain the stronger live-message revision check. Reading an old window does +not overwrite the composer's model/agent from historical messages. + +The old transcript stays visible during a jump. A newer target supersedes the +pending request, including when the new target is already resident. Cancellation, +leaving the view and manual transcript scrolling reject late navigation. Failed +jumps keep the visible page and retry the requested destination. Historical +windows remain isolated from live append events; returning to latest uses the +native latest-page loader. + +`session-outline.ts` owns metadata independently of resident transcript windows. +It refreshes on activation, terminal status, undo/content mutation and reconnect; +hidden views cancel work. A refresh retains the last successful snapshot and +errors provide an explicit retry. The initial outline scan is proportional to the +session size; a subsequent jump loads only its bounded destination window. + +The timeline reserves a narrow gutter in `timeline-scrollbar.css`. Hover/focus +changes contrast only. Marker rectangles give up width to the gutter; fixed-size +icons and vertical geometry never scale with available width. Browser checks load +the complete stylesheet and cover overflow, hover, keyboard focus, RTL and 125% +zoom. + +## Validation + +- Server SQL and route tests cover distant windows, sequence gaps, overlap, + cancellation, payload limits, staged undo and ownership/mutation races. +- `history-navigation.test.ts` exercises the real SessionView, transcript store, + native event dispatcher and SQL readers on a 1,500-message Chromium fixture. +- `test-session-navigation-native.mjs`, included in the isolated pruning fixture, + checks a 1,501-message session against real OpenCode 2.0.5 on Windows. +- These fixtures do not validate an installed Electron/Tauri application, WSL, + Linux or macOS. diff --git a/dev-docs/SESSION_HISTORY_QUERIES.md b/dev-docs/SESSION_HISTORY_QUERIES.md index 12839a0ea..e2a403044 100644 --- a/dev-docs/SESSION_HISTORY_QUERIES.md +++ b/dev-docs/SESSION_HISTORY_QUERIES.md @@ -53,9 +53,10 @@ names/arguments/text output/errors and readable native user/system/synthetic/ skill/shell/compaction text. Attachments and opaque continuation state are not searched. The technical-content switch excludes tool and reasoning parts. The UI advances empty pages but retains only one result page. Closing the window, -changing query/scope or leaving the session cancels obsolete UI work. A result -click fetches only that native message into a separate preview, never the -transcript store. Resident matches can still scroll to their existing row. +changing query/scope or leaving the session cancels obsolete UI work. Current-session +results navigate to a bounded transcript window around the selected message. +Other-session results fetch only that native message into a separate preview. +Resident matches scroll to their existing row without a network read. ## Cleanup semantics @@ -92,3 +93,9 @@ replays against a real daemon. The parent fixture also checks active-generation refusal and pre-compaction discovery. Never point this fixture at a user's DB or shared daemon. Native Windows, browser rendering and other platforms are separate validation claims; none implies the others. + +## Navigation extension + +The global timeline and current-session search now use direct bounded anchor +windows. See [SESSION_HISTORY_NAVIGATION.md](SESSION_HISTORY_NAVIGATION.md) for +the read contract, navigation/restore fences and scrollbar geometry. diff --git a/packages/server/src/opencode/session-pruning/history-database.ts b/packages/server/src/opencode/session-pruning/history-database.ts new file mode 100644 index 000000000..37318db55 --- /dev/null +++ b/packages/server/src/opencode/session-pruning/history-database.ts @@ -0,0 +1,42 @@ +import { randomUUID } from "node:crypto" +import { realpath } from "node:fs/promises" +import path from "node:path" +import type { DatabaseSync } from "node:sqlite" +import type { Plugin } from "@opencode/plugin" +import type { HistoryScope } from "./history-store" +import { pruningDatabasePath } from "./database-path" +import { storageKey } from "./claim-fence" +import { readLocationRef, sameLocation } from "./location" + +type Blocked = { status: "blocked"; reason: "not_deletable" | "unsupported_storage" } +export async function withHistoryDatabase(ctx: Plugin.Context, sessionID: string | undefined, signal: AbortSignal, + read: (db: DatabaseSync, scope: HistoryScope) => Promise): Promise { + const scope: HistoryScope = { ...readLocationRef(ctx.location), projectID: ctx.location.project.id } + if (sessionID) { + const session = await ctx.session.get({ sessionID }) + if (!sameLocation(readLocationRef(session.location), scope)) return { status: "blocked", reason: "not_deletable" } + scope.sessionID = session.id + scope.projectID = session.projectID + } + const configured = pruningDatabasePath(ctx.options.databasePath, ctx.app.channel) + if (typeof configured !== "string" || !path.isAbsolute(configured) || (process.platform === "win32" && configured.replaceAll("/", "\\").startsWith("\\\\"))) { + return { status: "blocked", reason: "unsupported_storage" } + } + const key = `history/binding/${randomUUID()}`, nonce = randomUUID() + signal.throwIfAborted() + await ctx.storage.set(key, nonce) + try { + const { DatabaseSync } = await import("node:sqlite") + const db = new DatabaseSync(await realpath(configured), { readOnly: true, timeout: 0 }) + try { + db.exec("PRAGMA query_only=ON") + if (db.prepare("SELECT value FROM kv WHERE key=?").get(storageKey(key))?.value !== JSON.stringify(nonce)) { + return { status: "blocked", reason: "unsupported_storage" } + } + return await read(db, scope) + } finally { db.close() } + } catch { + signal.throwIfAborted() + return { status: "blocked", reason: "unsupported_storage" } + } finally { await ctx.storage.remove(key).catch(() => {}) } +} diff --git a/packages/server/src/opencode/session-pruning/history-service.ts b/packages/server/src/opencode/session-pruning/history-service.ts index d665b017e..1cca0256d 100644 --- a/packages/server/src/opencode/session-pruning/history-service.ts +++ b/packages/server/src/opencode/session-pruning/history-service.ts @@ -1,47 +1,13 @@ -import { randomUUID } from "node:crypto" -import { realpath } from "node:fs/promises" -import path from "node:path" import type { Plugin } from "@opencode/plugin" import { historyQuerySchema, pruneBatchSchema, type HistoryNativeResult } from "./history-contract" -import { queryHistoryPage, type HistoryScope } from "./history-store" -import { pruningDatabasePath } from "./database-path" -import { storageKey } from "./claim-fence" -import { readLocationRef, sameLocation } from "./location" +import { queryHistoryPage } from "./history-store" +import { withHistoryDatabase } from "./history-database" import { pruneBoundMessage } from "./service" import type { PruneResult } from "./contract" export async function queryBoundHistory(ctx: Plugin.Context, value: unknown, signal: AbortSignal): Promise { const input = historyQuerySchema.parse(value) - const scope: HistoryScope = readLocationRef(ctx.location) - scope.projectID = ctx.location.project.id - if (input.sessionID) { - const session = await ctx.session.get({ sessionID: input.sessionID }) - if (!sameLocation(readLocationRef(session.location), scope)) return { status: "blocked", reason: "not_deletable" } - scope.sessionID = session.id - scope.projectID = session.projectID - } - const configured = pruningDatabasePath(ctx.options.databasePath, ctx.app.channel) - if (typeof configured !== "string" || !path.isAbsolute(configured) || (process.platform === "win32" && configured.replaceAll("/", "\\").startsWith("\\\\"))) { - return { status: "blocked", reason: "unsupported_storage" } - } - const key = `history/binding/${randomUUID()}` - const nonce = randomUUID() - signal.throwIfAborted() - await ctx.storage.set(key, nonce) - try { - const { DatabaseSync } = await import("node:sqlite") - const db = new DatabaseSync(await realpath(configured), { readOnly: true, timeout: 0 }) - try { - db.exec("PRAGMA query_only=ON") - if (db.prepare("SELECT value FROM kv WHERE key=?").get(storageKey(key))?.value !== JSON.stringify(nonce)) { - return { status: "blocked", reason: "unsupported_storage" } - } - return await queryHistoryPage(db, scope, input, signal) - } finally { db.close() } - } catch (error) { - signal.throwIfAborted() - return { status: "blocked", reason: "unsupported_storage" } - } finally { await ctx.storage.remove(key).catch(() => {}) } + return withHistoryDatabase(ctx, input.sessionID, signal, (db, scope) => queryHistoryPage(db, scope, input, signal)) } export async function pruneBoundBatch(ctx: Plugin.Context, value: unknown, signal: AbortSignal, diff --git a/packages/server/src/opencode/session-pruning/navigation-contract.ts b/packages/server/src/opencode/session-pruning/navigation-contract.ts new file mode 100644 index 000000000..7e74af679 --- /dev/null +++ b/packages/server/src/opencode/session-pruning/navigation-contract.ts @@ -0,0 +1,36 @@ +import { z } from "zod" +import { pruningBlockedSchema } from "./contract" + +const id = z.string().min(1).max(256) +export const navigationTargetSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("latest") }).strict(), + z.object({ kind: z.literal("oldest") }).strict(), + z.object({ kind: z.literal("around"), messageID: id }).strict(), + z.object({ kind: z.literal("before"), messageID: id }).strict(), + z.object({ kind: z.literal("after"), messageID: id }).strict(), +]) +export type NavigationTarget = z.infer +export const navigationWindowInputSchema = z.object({ sessionID: id, target: navigationTargetSchema }).strict() +const outlineCursorSchema = z.object({ after: z.number().int().nonnegative(), through: z.number().int().nonnegative() }).strict() +export const outlineInputSchema = z.object({ sessionID: id, + cursor: outlineCursorSchema.optional(), +}).strict() +const messageType = z.enum(["user", "assistant", "system", "synthetic", "skill", "shell", "compaction", "idle", "agent-switched", "model-switched", "location-switched"]) +export const outlineEntrySchema = z.object({ + id, seq: z.number().int().nonnegative(), type: messageType, + preview: z.string().max(220), chars: z.number().int().nonnegative(), + tools: z.number().int().nonnegative(), reasoning: z.number().int().nonnegative(), +}).strict() +export type OutlineEntry = z.infer +export const outlineResultSchema = z.union([pruningBlockedSchema, z.object({ + status: z.literal("outline"), entries: z.array(outlineEntrySchema).max(256), + total: z.number().int().nonnegative(), cursor: outlineCursorSchema.nullable(), +}).strict()]) +export type OutlineResult = z.infer +export const navigationWindowResultSchema = z.union([pruningBlockedSchema, z.object({ + status: z.literal("window"), + messages: z.array(z.object({ id, type: messageType, time: z.object({ created: z.number() }).passthrough() }).passthrough()).max(200), + older: navigationTargetSchema.nullable(), newer: navigationTargetSchema.nullable(), + resume: navigationTargetSchema, latest: z.boolean(), +}).strict()]) +export type NavigationWindowResult = z.infer diff --git a/packages/server/src/opencode/session-pruning/navigation-store.ts b/packages/server/src/opencode/session-pruning/navigation-store.ts new file mode 100644 index 000000000..2d4d67cae --- /dev/null +++ b/packages/server/src/opencode/session-pruning/navigation-store.ts @@ -0,0 +1,93 @@ +import type { DatabaseSync, SQLInputValue } from "node:sqlite" +import { setImmediate as yieldTurn } from "node:timers/promises" +import { storageDirectory } from "./storage-path" +import type { HistoryScope } from "./history-store" +import { navigationWindowResultSchema, type NavigationTarget, type NavigationWindowResult, type OutlineResult, type OutlineEntry } from "./navigation-contract" + +const PAGE_SIZE = 200 +const MESSAGE_BYTES = 16 * 1024 * 1024 +const WINDOW_BYTES = 24 * 1024 * 1024 + +function ownedSession(db: DatabaseSync, scope: HistoryScope) { + const session = db.prepare("SELECT revert FROM session_v2 WHERE id=? AND directory=? AND project_id=? AND workspace_id IS ?") + .get(scope.sessionID!, storageDirectory(scope.directory), scope.projectID!, scope.workspaceID ?? null) + if (!session) throw new Error("Session location changed") + const revert = typeof session.revert === "string" ? JSON.parse(session.revert) : undefined + // Use the same staged-undo visibility boundary as the transcript. Native + // storage retains the tail until commit; navigation must not expose it. + return typeof revert?.messageID === "string" ? revert.messageID as string : undefined +} + +export async function readNavigationWindow(db: DatabaseSync, scope: HistoryScope, target: NavigationTarget, signal: AbortSignal): Promise { + signal.throwIfAborted() + db.exec("BEGIN") + try { + const boundary = ownedSession(db, scope) + const where = "session_id=?" + (boundary ? " AND id < ?" : "") + const params: SQLInputValue[] = [scope.sessionID!, ...(boundary ? [boundary] : [])] + let sequence: number | undefined + if ("messageID" in target) { + const anchor = db.prepare(`SELECT seq FROM session_message WHERE ${where} AND id=?`).get(...params, target.messageID) + if (!anchor) return { status: "blocked", reason: "conflict" } + sequence = Number(anchor.seq) + } + const select = (clause: string, args: SQLInputValue[], ascending: boolean, count: number) => db.prepare( + `SELECT id,type,seq,length(CAST(data AS BLOB)) AS bytes FROM session_message WHERE ${where} ${clause} ORDER BY seq ${ascending ? "ASC" : "DESC"} LIMIT ?`, + ).all(...params, ...args, count) + const rows = target.kind === "around" + ? [...select("AND seq < ?", [sequence!], false, 80).reverse(), ...select("AND seq >= ?", [sequence!], true, 120)] + : target.kind === "latest" ? select("", [], false, PAGE_SIZE).reverse() + : target.kind === "oldest" ? select("", [], true, PAGE_SIZE) + : target.kind === "before" + ? [...select("AND seq < ?", [sequence!], false, 184).reverse(), ...select("AND seq >= ?", [sequence!], true, 16)] + : [...select("AND seq <= ?", [sequence!], false, 16).reverse(), ...select("AND seq > ?", [sequence!], true, 184)] + if (rows.some(row => Number(row.bytes) > MESSAGE_BYTES) || rows.reduce((n, row) => n + Number(row.bytes), 0) > WINDOW_BYTES) { + return { status: "blocked", reason: "unsupported_storage" } + } + const messages = [] + const read = db.prepare("SELECT data FROM session_message WHERE session_id=? AND id=?") + for (const row of rows) { + signal.throwIfAborted() + const stored = read.get(scope.sessionID!, row.id) + if (typeof stored?.data !== "string") return { status: "blocked", reason: "conflict" } + messages.push({ ...JSON.parse(stored.data), id: row.id, type: row.type }) + await yieldTurn(undefined, { signal }) + } + const first = rows[0], last = rows.at(-1) + const older = first && db.prepare(`SELECT 1 FROM session_message WHERE ${where} AND seq < ? LIMIT 1`).get(...params, first.seq) + const newer = last && db.prepare(`SELECT 1 FROM session_message WHERE ${where} AND seq > ? LIMIT 1`).get(...params, last.seq) + return navigationWindowResultSchema.parse({ status: "window", messages, + older: older ? { kind: "before", messageID: first!.id } : null, + newer: newer ? { kind: "after", messageID: last!.id } : null, + resume: target, + latest: !newer, + }) + } finally { db.exec("ROLLBACK") } +} + +export async function readSessionOutline(db: DatabaseSync, scope: HistoryScope, cursor: { after: number; through: number } | undefined, signal: AbortSignal): Promise { + signal.throwIfAborted() + const boundary = ownedSession(db, scope) + const where = "session_id=?" + (boundary ? " AND id < ?" : "") + const params: SQLInputValue[] = [scope.sessionID!, ...(boundary ? [boundary] : [])] + const through = cursor?.through ?? Number(db.prepare(`SELECT coalesce(max(seq),0) AS value FROM session_message WHERE ${where}`).get(...params)?.value) + const total = Number(db.prepare(`SELECT count(*) AS value FROM session_message WHERE ${where} AND seq<=?`).get(...params, through)?.value) + const rows = db.prepare(`SELECT id,type,seq,CASE WHEN length(CAST(data AS BLOB))<=? THEN data ELSE NULL END AS data + FROM session_message WHERE ${where} AND seq>? AND seq<=? ORDER BY seq LIMIT 256`) + const entries: OutlineEntry[] = [] + let after = cursor?.after ?? -1 + const started = performance.now() + for (const row of rows.iterate(MESSAGE_BYTES, ...params, after, through)) { + signal.throwIfAborted() + after = Number(row.seq) + const data = typeof row.data === "string" ? JSON.parse(row.data) : {} + const parts = Array.isArray(data.content) ? data.content : [] + const texts = parts.filter((part: any) => part.type === "text").map((part: any) => typeof part.text === "string" ? part.text : "") + const text = typeof data.text === "string" ? data.text : typeof data.summary === "string" ? data.summary : typeof data.command === "string" ? data.command : texts.join("\n") + entries.push({ id: String(row.id), seq: after, type: row.type as OutlineEntry["type"], preview: text.slice(0, 220), chars: text.length, + tools: parts.filter((part: any) => part.type === "tool").length, reasoning: parts.filter((part: any) => part.type === "reasoning").length }) + await yieldTurn(undefined, { signal }) + if (performance.now() - started > 40) break + } + return { status: "outline", entries, total, cursor: entries.length && after < through ? { after, through } : null } +} diff --git a/packages/server/src/opencode/session-pruning/navigation.test.ts b/packages/server/src/opencode/session-pruning/navigation.test.ts new file mode 100644 index 000000000..8d85a281f --- /dev/null +++ b/packages/server/src/opencode/session-pruning/navigation.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { DatabaseSync } from "node:sqlite" +import { readNavigationWindow, readSessionOutline } from "./navigation-store" + +const scope = { directory: "/repo", projectID: "p", sessionID: "s" } +const id = (n: number) => `message-${String(n).padStart(5, "0")}` +function fixture(count = 1500) { + const db = new DatabaseSync(":memory:") + db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT); + CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT); + CREATE UNIQUE INDEX session_message_session_seq_idx ON session_message(session_id,seq); + INSERT INTO session_v2 VALUES ('s','/repo','p',NULL,NULL);`) + const insert = db.prepare("INSERT INTO session_message VALUES (?,'s','user',?,?)") + for (let n = 0; n < count; n++) insert.run(id(n), n * 7, JSON.stringify({ text: `Passage ${n}`, time: { created: n + 1 } })) + return db +} +const signal = () => new AbortController().signal +test("direct distant windows retain native sequence order, bound content and overlap adjacent windows", async () => { + const db = fixture() + try { + const around = await readNavigationWindow(db, scope, { kind: "around", messageID: id(1000) }, signal()) + assert.equal(around.status, "window") + if (around.status !== "window") return + assert.equal(around.messages.length, 200) + assert.equal(around.messages[80].id, id(1000)) + assert.equal(around.messages[0].id, id(920)) + assert.equal(around.messages.at(-1)?.id, id(1119)) + assert.equal(around.latest, false) + assert.deepEqual(await readNavigationWindow(db, scope, around.resume, signal()), around) + const before = await readNavigationWindow(db, scope, around.older!, signal()) + assert.equal(before.status, "window") + if (before.status !== "window") return + assert.equal(before.messages.length, 200) + assert(before.messages.some(message => message.id === around.messages[0].id)) + const after = await readNavigationWindow(db, scope, around.newer!, signal()) + assert.equal(after.status, "window") + if (after.status !== "window") return + assert(after.messages.some(message => message.id === around.messages.at(-1)?.id)) + assert.equal(after.messages.at(-1)?.id, id(1303)) + assert.equal(db.isTransaction, false) + } finally { db.close() } +}) +test("outline covers the complete session without message payloads and excludes newly appended rows", async () => { + const db = fixture() + try { + const first = await readSessionOutline(db, scope, undefined, signal()) + assert.equal(first.status, "outline") + if (first.status !== "outline") return + assert.equal(first.total, 1500) + assert(first.cursor) + db.prepare("INSERT INTO session_message VALUES (?,'s','user',?,?)").run(id(1500), 1500 * 7, JSON.stringify({ text: "Later", time: { created: 2000 } })) + const ids = first.entries.map(entry => entry.id) + let cursor: { after: number; through: number } | null = first.cursor + while (cursor) { + const page = await readSessionOutline(db, scope, cursor, signal()) + assert.equal(page.status, "outline") + if (page.status !== "outline") break + assert(page.entries.length <= 256) + assert(page.entries.every(entry => entry.preview.length <= 220 && !("content" in entry))) + ids.push(...page.entries.map(entry => entry.id)); cursor = page.cursor + } + assert.equal(ids.length, 1500) + assert.equal(new Set(ids).size, ids.length) + assert.equal(ids.at(-1), id(1499)) + } finally { db.close() } +}) +test("navigation enforces ownership, message membership and staged undo visibility", async () => { + const db = fixture() + try { + await assert.rejects(readNavigationWindow(db, { ...scope, directory: "/foreign" }, { kind: "latest" }, signal())) + assert.equal(db.isTransaction, false) + assert.deepEqual(await readNavigationWindow(db, scope, { kind: "around", messageID: "foreign" }, signal()), { status: "blocked", reason: "conflict" }) + db.prepare("UPDATE session_v2 SET revert=?").run(JSON.stringify({ messageID: id(300) })) + const latest = await readNavigationWindow(db, scope, { kind: "latest" }, signal()) + assert.equal(latest.status, "window") + if (latest.status === "window") { + assert.equal(latest.messages.at(-1)?.id, id(299)) + assert.equal(latest.newer, null) + assert.equal(latest.latest, true) + } + assert.equal((await readNavigationWindow(db, scope, { kind: "around", messageID: id(1000) }, signal())).status, "blocked") + const outline = await readSessionOutline(db, scope, undefined, signal()) + if (outline.status === "outline") assert.equal(outline.total, 300) + } finally { db.close() } +}) +test("oversized windows fail explicitly and cancelled reads release their transaction", async () => { + const db = fixture(1) + try { + db.prepare("UPDATE session_message SET data=?").run(JSON.stringify({ text: "x".repeat(16 * 1024 * 1024), time: { created: 1 } })) + assert.equal((await readNavigationWindow(db, scope, { kind: "latest" }, signal())).status, "blocked") + const cancelled = new AbortController(); cancelled.abort() + await assert.rejects(readNavigationWindow(db, scope, { kind: "latest" }, cancelled.signal), /abort/i) + assert.equal(db.isTransaction, false) + } finally { db.close() } +}) diff --git a/packages/server/src/opencode/session-pruning/package.json b/packages/server/src/opencode/session-pruning/package.json index a74f6bdb5..72326fd44 100644 --- a/packages/server/src/opencode/session-pruning/package.json +++ b/packages/server/src/opencode/session-pruning/package.json @@ -12,6 +12,7 @@ "index.ts", "claim-fence.ts", "contract.ts", "database-path.ts", "location.ts", "planner.ts", "plugin.ts", "preview-store.ts", "revision.ts", "service.ts", "storage-path.ts", "history-contract.ts", "history-store.ts", "history-service.ts", + "history-database.ts", "navigation-contract.ts", "navigation-store.ts", "transaction.ts", "tui.ts", "tui-reload.ts" ], "dependencies": { diff --git a/packages/server/src/opencode/session-pruning/plugin.ts b/packages/server/src/opencode/session-pruning/plugin.ts index 0efef90ff..b3dddd6b1 100644 --- a/packages/server/src/opencode/session-pruning/plugin.ts +++ b/packages/server/src/opencode/session-pruning/plugin.ts @@ -8,11 +8,16 @@ import { pruningDatabasePath } from "./database-path" import { readLocationRef, sameLocation } from "./location" import { historyQuerySchema, historyNativeResultSchema, pruneBatchSchema, pruneBatchResultSchema } from "./history-contract" import { queryBoundHistory, pruneBoundBatch } from "./history-service" +import { navigationWindowInputSchema, navigationWindowResultSchema, outlineInputSchema, outlineResultSchema } from "./navigation-contract" +import { readNavigationWindow, readSessionOutline } from "./navigation-store" +import { withHistoryDatabase } from "./history-database" export const SessionPruningRpc = Rpc.define({ ...pruningRpcDefinition, methods: { ...pruningRpcDefinition.methods, history: { input: historyQuerySchema, output: historyNativeResultSchema }, pruneBatch: { input: pruneBatchSchema, output: pruneBatchResultSchema }, + window: { input: navigationWindowInputSchema, output: navigationWindowResultSchema }, + outline: { input: outlineInputSchema, output: outlineResultSchema }, } }) // Native local-plugin entry point. Only a user's pruning RPC changes content. @@ -20,6 +25,10 @@ export default Plugin.define({ id: "codenomad-session-pruning", async setup(ctx) { const registration = await ctx.rpc.register(SessionPruningRpc, { + window: (input, call) => withHistoryDatabase(ctx, input.sessionID, call.signal, + (db, scope) => readNavigationWindow(db, scope, input.target, call.signal)), + outline: (input, call) => withHistoryDatabase(ctx, input.sessionID, call.signal, + (db, scope) => readSessionOutline(db, scope, input.cursor, call.signal)), history: (input, call) => queryBoundHistory(ctx, input, call.signal), pruneBatch: (input, call) => pruneBoundBatch(ctx, input, call.signal, async (sessionID, result) => { await registration.events.emit("pruned", { sessionID, messageID: result.messageID, revision: result.revision }) diff --git a/packages/server/src/server/routes/session-navigation.test.ts b/packages/server/src/server/routes/session-navigation.test.ts new file mode 100644 index 000000000..7cf65862b --- /dev/null +++ b/packages/server/src/server/routes/session-navigation.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import Fastify from "fastify" +import { registerSessionNavigationRoutes } from "./session-navigation" + +function fixture() { + const app = Fastify() + let owned = true, session: any = { id: "s", projectID: "p", location: { directory: "/repo" } } + let afterRead = () => {} + const calls: any[] = [] + registerSessionNavigationRoutes(app, { workspaceManager: { + ownsLocation: async () => owned, + getSharedServiceClient: async () => ({ session: { get: async () => structuredClone(session) }, rpc: { + call: async (input: any) => { + calls.push(input); afterRead() + return { output: input.method === "outline" ? { status: "outline", entries: [], total: 0, cursor: null } + : { status: "window", messages: [], older: null, newer: null, resume: { kind: "latest" }, latest: true } } + }, + } }) as any, + } as any }) + return { app, calls, ownership: (value: boolean) => { owned = value }, race: (callback: () => void) => { afterRead = callback }, + change: (value: object) => { session = { ...session, ...value } } } +} +for (const method of ["outline", "window"]) test(`${method} validates ownership and fences location, project, revert and inventory changes`, async () => { + const f = fixture(), url = `/api/workspaces/w/session-history/${method}` + const payload = { sessionID: "s", ...(method === "window" ? { target: { kind: "around", messageID: "m" } } : {}) } + const send = () => f.app.inject({ method: "POST", url, payload }) + try { + for (const extra of [{ directory: "/foreign" }, { databasePath: "x" }, { sql: "SELECT 1" }]) { + assert.equal((await f.app.inject({ method: "POST", url, payload: { ...payload, ...extra } })).statusCode, 400) + } + f.ownership(false) + assert.equal((await send()).statusCode, 403) + assert.equal(f.calls.length, 0) + f.ownership(true) + assert.equal((await send()).json().status, method) + assert.deepEqual(f.calls[0].location, { directory: "/repo" }) + for (const change of [{ location: { directory: "/other" } }, { projectID: "clone" }, { revert: { messageID: "m" } }]) { + f.race(() => f.change(change)) + assert.deepEqual((await send()).json(), { status: "blocked", reason: "conflict" }) + } + f.race(() => f.ownership(false)) + assert.deepEqual((await send()).json(), { status: "blocked", reason: "conflict" }) + } finally { await f.app.close() } +}) diff --git a/packages/server/src/server/routes/session-navigation.ts b/packages/server/src/server/routes/session-navigation.ts new file mode 100644 index 000000000..82394a660 --- /dev/null +++ b/packages/server/src/server/routes/session-navigation.ts @@ -0,0 +1,36 @@ +import type { FastifyInstance } from "fastify" +import { PRUNING_RPC_ID } from "../../opencode/session-pruning/contract" +import { navigationWindowInputSchema, navigationWindowResultSchema, outlineInputSchema, outlineResultSchema } from "../../opencode/session-pruning/navigation-contract" +import { locationRequestOptions, readLocationRef, sameLocation } from "../../opencode/compatibility/location" +import type { HistoryRouteDeps } from "./session-history" + +export function registerSessionNavigationRoutes(app: FastifyInstance, deps: Pick) { + for (const method of ["window", "outline"] as const) { + app.post<{ Params: { id: string } }>(`/api/workspaces/:id/session-history/${method}`, { bodyLimit: 8192 }, async (request, reply) => { + const parsed = (method === "window" ? navigationWindowInputSchema : outlineInputSchema).safeParse(request.body) + if (!parsed.success) return reply.code(400).send({ error: "Invalid history navigation request" }) + const client = await deps.workspaceManager.getSharedServiceClient() + const session = await client.session.get({ sessionID: parsed.data.sessionID }) + const location = readLocationRef(session.location) + if (!await deps.workspaceManager.ownsLocation(request.params.id, location, client)) return reply.code(403).send({ error: "Session does not belong to workspace" }) + const controller = new AbortController() + const abort = () => { if (!reply.raw.writableEnded) controller.abort() } + reply.raw.once("close", abort) + try { + const response = await client.rpc.call({ rpcID: PRUNING_RPC_ID, method, + input: JSON.parse(JSON.stringify(parsed.data)), location: { directory: location.directory } }, + { ...locationRequestOptions(location), signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]) }) + const output = (method === "window" ? navigationWindowResultSchema : outlineResultSchema).parse(response.output) + const current = await client.session.get({ sessionID: parsed.data.sessionID }) + if (!sameLocation(location, readLocationRef(current.location)) || current.projectID !== session.projectID + || JSON.stringify(current.revert) !== JSON.stringify(session.revert) + || !await deps.workspaceManager.ownsLocation(request.params.id, current.location, client)) { + return { status: "blocked", reason: "conflict" } + } + return output + } catch { + return { status: "blocked", reason: "unavailable" } + } finally { reply.raw.removeListener("close", abort) } + }) + } +} diff --git a/packages/server/src/server/routes/session-pruning.ts b/packages/server/src/server/routes/session-pruning.ts index 4cbed47af..35c7f4c4a 100644 --- a/packages/server/src/server/routes/session-pruning.ts +++ b/packages/server/src/server/routes/session-pruning.ts @@ -2,9 +2,11 @@ import type { FastifyInstance } from "fastify" import { messageTargetSchema, PRUNING_RPC_ID, prunePreviewSchema, pruneRequestSchema, pruneResultSchema } from "../../opencode/session-pruning/contract" import { locationRequestOptions, readLocationRef } from "../../opencode/compatibility/location" import { registerSessionHistoryRoutes, type HistoryRouteDeps } from "./session-history" +import { registerSessionNavigationRoutes } from "./session-navigation" export function registerSessionPruningRoutes(app: FastifyInstance, deps: HistoryRouteDeps): void { registerSessionHistoryRoutes(app, deps) + registerSessionNavigationRoutes(app, deps) for (const method of ["preview", "prune"] as const) { app.post<{ Params: { id: string } }>(`/api/workspaces/:id/session-pruning/${method}`, { bodyLimit: 1024 * 1024 }, async (request, reply) => { const input = (method === "prune" ? pruneRequestSchema : messageTargetSchema).safeParse(request.body) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 44ae64035..2625e852b 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -24,6 +24,9 @@ import { getMessageSelectionActionPosition } from "../lib/message-selection-posi import { findHistoryMatches } from "../stores/session-history" import HistoryStatistics from "./history-statistics" import HistoryMessagePreview from "./history-message-preview" +import { createSessionOutline } from "../stores/session-outline" +import { sessions } from "../stores/session-state" +import { projectSessionOutline } from "./session-outline-projection" import SessionCleanupProgress from "./session-cleanup-progress" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" @@ -71,6 +74,7 @@ export interface MessageSectionProps { onLoadNewerMessages?: (signal?: AbortSignal) => Promise onLoadLatestMessages?: (signal?: AbortSignal) => Promise onLoadOldestMessages?: (signal?: AbortSignal) => Promise + onLoadMessageAnchor?: (messageId: string, signal?: AbortSignal) => Promise getMessageHistoryCursor?: () => string | undefined isActive?: boolean sessionStreamingActive?: boolean @@ -262,7 +266,12 @@ export default function MessageSection(props: MessageSectionProps) { } setActiveSegmentId(segment.id) - scrollToMessage() + if (messageIds().includes(segment.messageId)) { + cancelWindowNavigation() + listApi()?.setAutoScroll(false) + scrollToMessage() + } + else void pageWindow("around", () => scrollToMessage(), segment.messageId) } const [expandedMessageIds, setExpandedMessageIds] = createSignal>(new Set()) @@ -306,7 +315,7 @@ export default function MessageSection(props: MessageSectionProps) { const searchFailed = createMemo(() => hasMessageSearchAuthority(trimmedSearchQuery(), failedSearchQuery())) const timelineSegmentCache = new Map() - const timelineSegments = createMemo(() => { + const residentTimelineSegments = createMemo(() => { sessionRevision() const ids = visibleMessageIds() const resolvedStore = store() @@ -336,6 +345,13 @@ export default function MessageSection(props: MessageSectionProps) { return segments }) }) + const outline = createSessionOutline({ instanceId: () => props.instanceId, sessionId: () => props.sessionId, + active: () => props.isActive !== false && Boolean(props.onLoadMessageAnchor) && showMessageTimelinePreference() }) + const timelineSegments = createMemo(() => { + const boundary = sessions().get(props.instanceId)?.get(props.sessionId)?.revert?.messageID + const entries = boundary ? outline.entries().filter(entry => entry.id < boundary) : outline.entries() + return entries.length ? projectSessionOutline(entries, residentTimelineSegments(), t) : residentTimelineSegments() + }) const hasTimelineSegments = () => timelineSegments().length > 0 function segmentMatchesSearch(segment: TimelineSegment, match: { messageId: string; partId?: string; partType?: string }): boolean { @@ -389,13 +405,27 @@ export default function MessageSection(props: MessageSectionProps) { let scrollRestoreGeneration = 0 let pagingWindow = false let pagingWindowController: AbortController | null = null + let anchorRestoreController: AbortController | null = null + const [navigationPending, setNavigationPending] = createSignal(false) + function cancelWindowNavigation() { + if (anchorRestoreController) { + anchorRestoreController.abort() + anchorRestoreController = null + scrollRestoreGeneration += 1 + restoringScrollSnapshot = false + setDidRestoreScroll(true) + } + pagingWindowController?.abort() + pagingWindowController = null + pagingWindow = false + setNavigationPending(false) + } let retryAnchorRestore: (() => void) | null = null const [olderMessageLoadFailed, setOlderMessageLoadFailed] = createSignal(false) function registerListApi(api: VirtualFollowListApi) { if (listApi() !== api) { - pagingWindowController?.abort() - pagingWindowController = null + cancelWindowNavigation() scrollRestoreGeneration += 1 restoringScrollSnapshot = false setDidRestoreScroll(false) @@ -417,8 +447,7 @@ export default function MessageSection(props: MessageSectionProps) { on( () => props.sessionId, () => { - pagingWindowController?.abort() - pagingWindowController = null + cancelWindowNavigation() scrollRestoreGeneration += 1 restoringScrollSnapshot = false restoredWithoutSnapshot = false @@ -443,8 +472,7 @@ export default function MessageSection(props: MessageSectionProps) { return } const wasRestoringScrollSnapshot = restoringScrollSnapshot - pagingWindowController?.abort() - pagingWindowController = null + cancelWindowNavigation() scrollRestoreGeneration += 1 retryAnchorRestore = null if (!wasRestoringScrollSnapshot) persistMessageScrollSnapshot({ requireActive: false }) @@ -454,7 +482,7 @@ export default function MessageSection(props: MessageSectionProps) { ), ) - onCleanup(() => pagingWindowController?.abort()) + onCleanup(cancelWindowNavigation) function canCaptureScrollSnapshot(options?: { requireActive?: boolean }) { const element = streamElement() @@ -611,13 +639,18 @@ export default function MessageSection(props: MessageSectionProps) { restoringScrollSnapshot = true const restore = async () => { setOlderMessageLoadFailed(false) - if (!snapshot.atBottom && snapshot.anchorKey && !visibleMessageIds().includes(snapshot.anchorKey) && props.onLoadMoreMessages) { + if (!snapshot.atBottom && snapshot.anchorKey && !visibleMessageIds().includes(snapshot.anchorKey) && (props.onLoadMessageAnchor || props.onLoadMoreMessages)) { try { - await loadPagesUntilAnchor({ + if (props.onLoadMessageAnchor) { + const controller = new AbortController() + anchorRestoreController = controller + try { await props.onLoadMessageAnchor(snapshot.anchorKey, controller.signal) } + finally { if (anchorRestoreController === controller) anchorRestoreController = null } + } else await loadPagesUntilAnchor({ hasAnchor: () => visibleMessageIds().includes(snapshot.anchorKey!), hasMore: () => Boolean(props.hasMoreMessages), isCurrent: isCurrentRestore, - loadMore: props.onLoadMoreMessages, + loadMore: props.onLoadMoreMessages!, getCursor: () => props.getMessageHistoryCursor?.(), }) } catch (error) { @@ -709,6 +742,20 @@ export default function MessageSection(props: MessageSectionProps) { const count = currentSearchMatches().length if (count === 0) return setActiveSearchIndex((index) => (index + direction + count) % count) + const match = activeSearchMatch() + if (match) navigateSearchMatch(match) + } + + function navigateSearchMatch(match: { sessionId: string; messageId: string }) { + if (match.sessionId !== props.sessionId || !props.onLoadMessageAnchor) { + setPreviewSearchMatch(match) + return + } + if (messageIds().includes(match.messageId)) { + cancelWindowNavigation() + listApi()?.setAutoScroll(false) + listApi()?.scrollToKey(match.messageId, { block: "start" }) + } else void pageWindow("around", api => api.scrollToKey(match.messageId, { block: "start" }), match.messageId) } function isSelectionWithinStream(range: Range | null) { @@ -815,12 +862,17 @@ export default function MessageSection(props: MessageSectionProps) { } async function pageWindow( - direction: "older" | "newer" | "latest" | "oldest", + direction: "older" | "newer" | "latest" | "oldest" | "around", after: (api: VirtualFollowListApi) => void, + anchorMessageId?: string, ) { const api = listApi() - if (!api || !isActive() || pagingWindow) return - const load = direction === "older" + if (!api || !isActive()) return + if (pagingWindow && direction !== "around" && direction !== "latest") return + cancelWindowNavigation() + const load = direction === "around" + ? props.onLoadMessageAnchor && anchorMessageId ? (signal?: AbortSignal) => props.onLoadMessageAnchor!(anchorMessageId, signal) : undefined + : direction === "older" ? props.onLoadMoreMessages : direction === "newer" ? props.onLoadNewerMessages @@ -847,9 +899,14 @@ export default function MessageSection(props: MessageSectionProps) { && !controller.signal.aborted && isScrollRestoreGenerationCurrent(sessionId, generation, props.sessionId, scrollRestoreGeneration) pagingWindow = true + setNavigationPending(direction === "around") + if (direction !== "latest") api.setAutoScroll(false) + setOlderMessageLoadFailed(false) + retryAnchorRestore = null try { if (!isCurrent()) return const previousPage = messageWindowPageKey() + const previousPosition = direction === "older" || direction === "newer" ? api.captureScrollSnapshot() : undefined await load(controller.signal) if (!isCurrent()) return // An empty boundary probe retires the older cursor without changing the @@ -859,8 +916,10 @@ export default function MessageSection(props: MessageSectionProps) { api.notifyContentRendered() await waitTwoFrames() if (!isCurrent()) return - after(api) - const bottomSettlement = direction === "older" || direction === "latest" + const retainedAnchor = previousPosition?.anchorKey && visibleMessageIds().includes(previousPosition.anchorKey) + if (retainedAnchor) api.restoreScrollSnapshot({ ...previousPosition, atBottom: false, followModeType: "escaped" }) + else after(api) + const bottomSettlement = !retainedAnchor && (direction === "older" || direction === "latest") ? await api.settleAtBottom() : "settled" if (!isCurrent()) return @@ -870,9 +929,11 @@ export default function MessageSection(props: MessageSectionProps) { if (!isCurrent()) return pagingWindowController = null pagingWindow = false + setNavigationPending(false) persistMessageScrollSnapshot({ snapshot: api.captureScrollSnapshot() }) } catch (error) { if (isCurrent()) { + retryAnchorRestore = () => { void pageWindow(direction, after, anchorMessageId) } setOlderMessageLoadFailed(true) log.error("Failed to page message window", { instanceId: props.instanceId, sessionId, direction, error }) } @@ -880,6 +941,7 @@ export default function MessageSection(props: MessageSectionProps) { if (pagingWindowController === controller) { pagingWindowController = null pagingWindow = false + setNavigationPending(false) } } } @@ -1084,6 +1146,18 @@ export default function MessageSection(props: MessageSectionProps) { data-stream-active={isActive() ? "true" : "false"} > + +
+ {t("history.navigation.loading")} + +
+
+ +
+ {t("history.navigation.outlineUnavailable")} + +
+
{ void pageWindow("older", (api) => api.scrollToBottom({ immediate: true })) }} + onScrollIntent={() => { if (navigationPending()) cancelWindowNavigation() }} onUserReachedBottom={() => { void pageWindow("newer", (api) => api.scrollToTop({ immediate: true })) }} onJumpTop={() => { void pageWindow("oldest", (api) => api.scrollToTop({ immediate: true })) }} onJumpBottom={() => { void pageWindow("latest", (api) => api.scrollToBottom({ immediate: true })) }} @@ -1233,7 +1308,7 @@ export default function MessageSection(props: MessageSectionProps) { title={t("messageSection.loadError.title")} error={props.loadError!} retryLabel={t("messageSection.loadError.reload")} - onRetry={() => props.onReloadMessages?.()} + onRetry={() => retryAnchorRestore ? retryAnchorRestore() : props.onReloadMessages?.()} /> @@ -1384,7 +1459,10 @@ export default function MessageSection(props: MessageSectionProps) {
{(match, index) => ( diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index 726bdea27..36422045d 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -452,8 +452,7 @@ const MessageTimeline: Component = (props) => { const segment = hoveredSegment() if (!segment) return null const record = store().getMessage(segment.messageId) - if (!record) return null - return { messageId: segment.messageId } + return { messageId: segment.messageId, resident: Boolean(record), summary: segment.tooltip } }) // Pre-computed set of messageIds that have at least one tool segment. @@ -566,6 +565,7 @@ const MessageTimeline: Component = (props) => { setScrollElement(element) }} class="message-timeline" + data-segment-count={props.segments.length} role="navigation" aria-label={t("messageTimeline.ariaLabel")} onScroll={handleScroll} @@ -608,6 +608,8 @@ const MessageTimeline: Component = (props) => { data-variant={segment.variant} class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSearchMatch() ? "message-timeline-segment-search-match" : ""} ${isActiveSearchMatch() ? "message-timeline-segment-search-active" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`} aria-current={isActive() ? "true" : undefined} + aria-label={segment.tooltip || segment.label} + data-message-id={segment.messageId} aria-hidden={isHidden() ? "true" : undefined} onClick={() => props.onSegmentClick?.(segment)} onMouseEnter={(event) => handleMouseEnter(segment, event)} @@ -632,12 +634,12 @@ const MessageTimeline: Component = (props) => { onMouseEnter={() => clearCloseTimer()} onMouseLeave={() => scheduleClose()} > - {data().summary}

}> + />
) diff --git a/packages/ui/src/components/session-outline-projection.ts b/packages/ui/src/components/session-outline-projection.ts new file mode 100644 index 000000000..b4c6cfe20 --- /dev/null +++ b/packages/ui/src/components/session-outline-projection.ts @@ -0,0 +1,28 @@ +import type { OutlineEntry } from "../../../server/src/opencode/session-pruning/navigation-contract" +import type { TimelineSegment } from "./message-timeline" + +export function projectSessionOutline(entries: readonly OutlineEntry[], resident: readonly TimelineSegment[], t: (key: string, params?: Record) => string): TimelineSegment[] { + const byMessage = new Map() + for (const segment of resident) { + const list = byMessage.get(segment.messageId) ?? [] + list.push(segment); byMessage.set(segment.messageId, list) + } + const metadata = new Map(entries.map(entry => [entry.id, entry])) + const ids = [...entries.map(entry => entry.id), ...[...byMessage.keys()].filter(id => !metadata.has(id))] + return ids.flatMap(id => { + const entry = metadata.get(id) + const local = byMessage.get(id) ?? [] + const nativeType = entry?.type ?? local.find(segment => segment.type !== "tool")?.type ?? "assistant" + if (!["user", "assistant", "compaction", "shell"].includes(nativeType)) return [] + const type = nativeType === "user" ? "user" : nativeType === "compaction" ? "compaction" : "assistant" + const label = t(`messageTimeline.segment.${type}.label`) + const text = local.filter(segment => segment.type !== "tool").map(segment => segment.tooltip).join("\n") || entry?.preview || label + const chars = local.length ? local.reduce((n, segment) => n + segment.totalChars, 0) : entry?.chars ?? 0 + const tools = entry?.tools ?? local.filter(segment => segment.type === "tool").length + const result: TimelineSegment[] = [{ id: `${id}:outline`, messageId: id, type, label, tooltip: text.slice(0, 220), totalChars: chars }] + if (tools) result.unshift({ id: `${id}:outline-tools`, messageId: id, type: "tool", label: t("messageTimeline.tool.fallbackLabel"), + tooltip: t("history.counts", { messages: 1, tools, reasoning: entry?.reasoning ?? 0 }), totalChars: tools * 100, + toolPartIds: local.flatMap(segment => segment.toolPartIds ?? []) }) + return result + }) +} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 06b38e459..5e133091c 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -11,7 +11,7 @@ import PromptContextControls from "../prompt-input/PromptContextControls" import { observeTimelineRailBoundary } from "./timeline-rail-boundary" import { addAttachment, clearAttachments, getAttachments, removeAttachment } from "../../stores/attachments" import { instances, waitForInstanceReady } from "../../stores/instances" -import { getMessageNextCursor, hasMoreMessages, isLatestMessageWindow, loadLatestMessageWindow, loadMessages, loadMoreMessages, loadNewerMessageWindow, loadOldestMessageWindow, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession, backgroundSession } from "../../stores/sessions" +import { getMessageNextCursor, hasMoreMessages, isLatestMessageWindow, loadLatestMessageWindow, loadMessages, loadMoreMessages, loadNewerMessageWindow, loadOldestMessageWindow, loadMessageAnchor, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession, backgroundSession } from "../../stores/sessions" import { canMarkSessionIdleSeen } from "./session-idle-attention" import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, getSessionStatus, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status" import { showAlertDialog } from "../../stores/alerts" @@ -617,6 +617,7 @@ export const SessionView: Component = (props) => { onLoadNewerMessages={(signal) => loadNewerMessageWindow(props.instanceId, props.sessionId, signal)} onLoadLatestMessages={(signal) => loadLatestMessageWindow(props.instanceId, props.sessionId, signal)} onLoadOldestMessages={(signal) => loadOldestMessageWindow(props.instanceId, props.sessionId, signal)} + onLoadMessageAnchor={(messageId, signal) => loadMessageAnchor(props.instanceId, props.sessionId, messageId, signal)} sessionStreamingActive={sessionStreamingActive()} explicitBottomPinIntent={activeSubmitBottomPinIntent()} onExplicitBottomPinCancelled={() => setSubmitBottomPinIntent(null)} diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 6d28dd314..537cb2f8c 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -1,4 +1,5 @@ import type { HistoryQuery, HistoryResult, PruneBatch, PruneBatchResult } from "../../../server/src/opencode/session-pruning/history-contract" +import type { NavigationTarget, NavigationWindowResult, OutlineResult } from "../../../server/src/opencode/session-pruning/navigation-contract" import type { PruneRequest, PruneResult, @@ -177,6 +178,16 @@ async function requestRaw(path: string, init?: RequestInit): Promise { export const serverApi = { + fetchHistoryWindow(instanceId: string, sessionID: string, target: NavigationTarget, signal?: AbortSignal): Promise { + return request(`/api/workspaces/${encodeURIComponent(instanceId)}/session-history/window`, { + method: "POST", body: JSON.stringify({ sessionID, target }), signal, + }) + }, + fetchSessionOutline(instanceId: string, sessionID: string, cursor?: { after: number; through: number }, signal?: AbortSignal): Promise { + return request(`/api/workspaces/${encodeURIComponent(instanceId)}/session-history/outline`, { + method: "POST", body: JSON.stringify({ sessionID, cursor }), signal, + }) + }, querySessionHistory(instanceId: string, input: HistoryQuery, signal?: AbortSignal): Promise { return request(`/api/workspaces/${encodeURIComponent(instanceId)}/session-history/query`, { method: "POST", body: JSON.stringify(input), signal, diff --git a/packages/ui/src/lib/i18n/messages/de/messaging.ts b/packages/ui/src/lib/i18n/messages/de/messaging.ts index a24f32472..03d152110 100644 --- a/packages/ui/src/lib/i18n/messages/de/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/de/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "Abschnitt wird geladen…", + "history.navigation.outlineUnavailable": "Vollständige Zeitleiste nicht verfügbar", "history.scope": "Suchbereich", "history.session": "Gesamte Sitzung", "history.workspace": "Gesamter Arbeitsbereich", diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index 11de280a2..f02bd57e7 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "Loading passage…", + "history.navigation.outlineUnavailable": "Full timeline unavailable", "history.scope": "Search scope", "history.session": "Entire session", "history.workspace": "Entire workspace", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index e35d9801c..26fdc8a38 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "Cargando el pasaje…", + "history.navigation.outlineUnavailable": "Cronología completa no disponible", "history.scope": "Ámbito de búsqueda", "history.session": "Toda la sesión", "history.workspace": "Todo el espacio de trabajo", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index fd01f93e4..feed0aaef 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "Chargement du passage…", + "history.navigation.outlineUnavailable": "Timeline complète indisponible", "history.scope": "Périmètre de recherche", "history.session": "Session entière", "history.workspace": "Workspace entier", diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index 4ceaefcce..9c95eb8a6 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "טוען קטע…", + "history.navigation.outlineUnavailable": "ציר הזמן המלא אינו זמין", "history.scope": "היקף החיפוש", "history.session": "כל השיחה", "history.workspace": "כל סביבת העבודה", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index 84f6e58ce..b9365cd3f 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "該当箇所を読み込み中…", + "history.navigation.outlineUnavailable": "全体のタイムラインを取得できません", "history.scope": "検索範囲", "history.session": "セッション全体", "history.workspace": "ワークスペース全体", diff --git a/packages/ui/src/lib/i18n/messages/ne/messaging.ts b/packages/ui/src/lib/i18n/messages/ne/messaging.ts index f0d90c09f..3b3b6e177 100644 --- a/packages/ui/src/lib/i18n/messages/ne/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ne/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "अंश लोड हुँदैछ…", + "history.navigation.outlineUnavailable": "पूरा टाइमलाइन उपलब्ध छैन", "history.scope": "खोजको दायरा", "history.session": "पूरै सत्र", "history.workspace": "पूरै कार्यस्थान", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index 6f776c9a3..4217223a4 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "Загрузка фрагмента…", + "history.navigation.outlineUnavailable": "Полная шкала времени недоступна", "history.scope": "Область поиска", "history.session": "Вся сессия", "history.workspace": "Всё рабочее пространство", diff --git a/packages/ui/src/lib/i18n/messages/tr/messaging.ts b/packages/ui/src/lib/i18n/messages/tr/messaging.ts index c583ac609..07e73d57c 100644 --- a/packages/ui/src/lib/i18n/messages/tr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/tr/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "Bölüm yükleniyor…", + "history.navigation.outlineUnavailable": "Tam zaman çizelgesi kullanılamıyor", "history.scope": "Arama kapsamı", "history.session": "Tüm oturum", "history.workspace": "Tüm çalışma alanı", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index 076d2e090..5cfb72e8c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -1,4 +1,6 @@ export const messagingMessages = { + "history.navigation.loading": "正在加载所选段落…", + "history.navigation.outlineUnavailable": "完整时间线暂不可用", "history.scope": "搜索范围", "history.session": "整个会话", "history.workspace": "整个工作区", diff --git a/packages/ui/src/stores/history-window.ts b/packages/ui/src/stores/history-window.ts new file mode 100644 index 000000000..b2596cf50 --- /dev/null +++ b/packages/ui/src/stores/history-window.ts @@ -0,0 +1,28 @@ +import type { SessionMessageInfo, SessionMessagesResponse } from "@opencode/client" +import { navigationTargetSchema, type NavigationTarget } from "../../../server/src/opencode/session-pruning/navigation-contract" +import { serverApi } from "../lib/api-client" +import { tGlobal } from "../lib/i18n" +import type { MessageWindowState } from "./message-v2/message-window" + +// CodeNomad cursors are explicitly namespaced and never sent to native APIs. +// Persist the stable target rather than a traversed path of page cursors. +const PREFIX = "codenomad:history-window:1:" +export const historyWindowCursor = (target: NavigationTarget) => PREFIX + encodeURIComponent(JSON.stringify(target)) +export function historyWindowTarget(cursor: string | undefined): NavigationTarget | undefined { + if (!cursor?.startsWith(PREFIX)) return undefined + return navigationTargetSchema.parse(JSON.parse(decodeURIComponent(cursor.slice(PREFIX.length)))) +} + +export async function readHistoryWindow(instanceId: string, sessionId: string, target: NavigationTarget, signal?: AbortSignal): Promise<{ response: SessionMessagesResponse; window: MessageWindowState }> { + const result = await serverApi.fetchHistoryWindow(instanceId, sessionId, target, signal) + signal?.throwIfAborted() + if (result.status !== "window") throw new Error(tGlobal(`session.pruning.${result.reason}`)) + return { + // The isolated native regression compares every reconstructed message to + // session.message.get/export, including provider state and metadata. + response: { data: result.messages as SessionMessageInfo[], cursor: {} }, + window: { kind: result.latest ? "latest" : "history", resumeCursor: historyWindowCursor(result.resume), + olderCursor: result.older ? historyWindowCursor(result.older) : undefined, + newerCursors: [result.newer ? historyWindowCursor(result.newer) : null] }, + } +} diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts index 63678fcb6..71824ff6c 100644 --- a/packages/ui/src/stores/opencode-data.ts +++ b/packages/ui/src/stores/opencode-data.ts @@ -85,6 +85,7 @@ function bumpMutationRevision(key: string): void { } export function getOpenCodeInstanceGeneration(instanceId: string): number { + instanceDataRevision(instanceId)[0]() let generation = instanceGenerations.get(instanceId) if (generation === undefined) { generation = ++nextInstanceGeneration diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index e2450c729..1be15bc40 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -6,6 +6,7 @@ import { import type { Message } from "../types/message" import type { Instance } from "../types/instance" import { forkAfterMessage } from "./session-fork" +import { historyWindowCursor, historyWindowTarget, readHistoryWindow } from "./history-window" import { ensureWorktreesLoaded, getGitRepoStatus, getWorktrees } from "./worktrees" import { selectWorkspaceSessionFamilies } from "./workspace-session-scope" import { isSessionNotFoundError, type LocationRef, type SessionInfo as SDKSession, type SessionMessagesResponse } from "@opencode/client" @@ -113,7 +114,7 @@ const MESSAGE_STREAM_SCOPE = "message-stream" const MAX_LATEST_WINDOW_REVISION_RETRIES = 3 const LATEST_WINDOW_RETRY_DELAY_MS = 50 const MESSAGE_CURSOR_SEEK_LIMIT = 1000 -type MessageWindowIntent = "open" | "older" | "newer" | "latest" | "oldest" +type MessageWindowIntent = "open" | "older" | "newer" | "latest" | "oldest" | "around" let nextSessionListRequestId = 0 let nextAgentRequestId = 0 let nextProviderRequestId = 0 @@ -1403,6 +1404,7 @@ function commitMessageWindow( sessionId: string, window: MessageWindowState, intent: MessageWindowIntent, + anchorMessageId?: string, ) { const store = messageStoreBus.getOrCreate(instanceId) store.setMessageWindow(sessionId, window) @@ -1414,8 +1416,8 @@ function commitMessageWindow( atBottom: preservePosition ? existing?.atBottom ?? window.kind === "latest" : atBottom, scrollRatio: preservePosition ? existing?.scrollRatio : undefined, maxScrollTop: preservePosition ? existing?.maxScrollTop : undefined, - anchorKey: preservePosition ? existing?.anchorKey : undefined, - anchorOffset: preservePosition ? existing?.anchorOffset : undefined, + anchorKey: anchorMessageId ?? (preservePosition ? existing?.anchorKey : undefined), + anchorOffset: anchorMessageId ? 0 : preservePosition ? existing?.anchorOffset : undefined, followModeType: preservePosition ? existing?.followModeType : intent === "latest" ? "following" : "escaped", ...toWindowSnapshot(window), }) @@ -1437,6 +1439,7 @@ type MessageLoadOptions = { registerInvalidation?: (invalidate: () => void) => void signal?: AbortSignal revisionRetry?: number + anchorMessageId?: string } async function loadMessages( @@ -1451,7 +1454,9 @@ async function loadMessages( const storedWindow = store.getMessageWindow(sessionId) const scrollSnapshot = store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE) const currentWindow = storedWindow ?? windowFromSnapshot(scrollSnapshot) - const planned = planMessageWindowLoad(currentWindow, intent) + const planned: ReturnType = options?.anchorMessageId + ? { cursor: historyWindowCursor({ kind: "around", messageID: options.anchorMessageId }), next: currentWindow } + : planMessageWindowLoad(currentWindow, intent) if (!planned) return const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId) @@ -1506,9 +1511,18 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) let response: SessionMessagesResponse + let directWindow: MessageWindowState | undefined let resolvedNext = planned.next let responseAscending = planned.order === "asc" || planned.forward - if (planned.seekNewer) { + const lastResident = planned.seekNewer ? store.getSessionMessageIds(sessionId).at(-1) : undefined + const directTarget = historyWindowTarget(planned.cursor) + ?? (lastResident ? { kind: "after" as const, messageID: lastResident } : undefined) + if (directTarget) { + const direct = await readHistoryWindow(instanceId, sessionId, directTarget, options?.signal) + response = direct.response + directWindow = direct.window + responseAscending = true + } else if (planned.seekNewer) { const seen = new Set() let cursor: string | undefined for (let page = 0; ; page += 1) { @@ -1542,7 +1556,7 @@ async function loadMessages( // On opening/latest, seek the latest *visible* page rather than treating // a page consisting entirely of that hidden tail as an empty session. if (!isCurrent()) return - if ((intent === "open" || intent === "latest") && !planned.cursor && session.revert?.messageID) { + if (!directWindow && (intent === "open" || intent === "latest") && !planned.cursor && session.revert?.messageID) { const boundary = session.revert.messageID const seen = new Set() while (response.data.length > 0 && response.data.every((message) => message.id >= boundary)) { @@ -1566,11 +1580,11 @@ async function loadMessages( if (!isCurrent()) return const forwardPage = intent === "oldest" || planned.forward - const nextWindow = forwardPage + const nextWindow = directWindow ?? (forwardPage ? newerCursor ? { ...resolvedNext, olderCursor: undefined, newerCursors: [newerCursor] } : emptyLatestWindow() - : withOlderCursor(resolvedNext, olderCursor) + : withOlderCursor(resolvedNext, olderCursor)) const hasLatestRevisionConflict = () => nextWindow.kind === "latest" && getOpenCodeMessageRevision(instanceId, sessionId) !== liveMessageRevision const apiMessages = responseAscending ? [...response.data] : [...response.data].reverse() @@ -1591,7 +1605,7 @@ async function loadMessages( // page. It still carries session metadata authority: late projections // must retain the boundary, and a cleared boundary must not linger. store.setSessionRevert(sessionId, sessions().get(instanceId)?.get(sessionId)?.revert ?? null) - commitMessageWindow(instanceId, sessionId, nextWindow, intent) + commitMessageWindow(instanceId, sessionId, nextWindow, intent, options?.anchorMessageId) markSessionMessagesLoaded(instanceId, sessionId) } } else { @@ -1630,7 +1644,7 @@ async function loadMessages( modelID = defaultModel.modelId } - setSessions((prev) => { + if (nextWindow.kind === "latest") setSessions((prev) => { if (!isCurrent()) return prev const next = new Map(prev) const nextInstanceSessions = next.get(instanceId) @@ -1654,7 +1668,7 @@ async function loadMessages( if (hasLatestRevisionConflict() || !seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, expectedRevision, false)) { retryAfterRevisionConflict = true } else { - commitMessageWindow(instanceId, sessionId, nextWindow, intent) + commitMessageWindow(instanceId, sessionId, nextWindow, intent, options?.anchorMessageId) markSessionMessagesLoaded(instanceId, sessionId) reconcilePendingPermissionsV2(instanceId, sessionId) } @@ -1693,6 +1707,7 @@ async function loadMessages( registerInvalidation: options?.registerInvalidation, signal: options?.signal, revisionRetry: revisionRetry + 1, + anchorMessageId: options?.anchorMessageId, }) } @@ -1782,6 +1797,10 @@ function loadOldestMessageWindow(instanceId: string, sessionId: string, signal?: return enqueueMessageWindowLoad(instanceId, sessionId, "oldest", signal) } +function loadMessageAnchor(instanceId: string, sessionId: string, messageId: string, signal?: AbortSignal): Promise { + return loadMessages(instanceId, sessionId, { force: true, intent: "around", anchorMessageId: messageId, signal }) +} + function hasMoreMessages(instanceId: string, sessionId: string): boolean { return Boolean(currentMessageWindow(instanceId, sessionId).olderCursor) } @@ -1816,6 +1835,7 @@ export { loadNewerMessageWindow, loadLatestMessageWindow, loadOldestMessageWindow, + loadMessageAnchor, hasMoreMessages, getMessageNextCursor, isLatestMessageWindow, diff --git a/packages/ui/src/stores/session-outline.ts b/packages/ui/src/stores/session-outline.ts new file mode 100644 index 000000000..98625c7bf --- /dev/null +++ b/packages/ui/src/stores/session-outline.ts @@ -0,0 +1,55 @@ +import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" +import type { OutlineEntry } from "../../../server/src/opencode/session-pruning/navigation-contract" +import { serverApi } from "../lib/api-client" +import { tGlobal } from "../lib/i18n" +import { getOpenCodeInstanceGeneration, getOpenCodeMutationRevision } from "./opencode-data" +import { sessions } from "./session-state" + +// Metadata has its own lifetime: changing the resident 200-message window never +// evicts the outline. Hidden views cancel work; refresh retains the last snapshot. +export function createSessionOutline(props: { instanceId: Accessor; sessionId: Accessor; active: Accessor }) { + const [entries, setEntries] = createSignal([]) + const [pending, setPending] = createSignal(false) + const [error, setError] = createSignal("") + const [retry, setRetry] = createSignal(0) + const sessionState = createMemo(() => { + const session = sessions().get(props.instanceId())?.get(props.sessionId()) + return `${session?.status}\0${session?.revert?.messageID}` + }) + let identity = "" + createEffect(() => { + const instanceId = props.instanceId(), sessionID = props.sessionId() + const generation = getOpenCodeInstanceGeneration(instanceId) + const key = JSON.stringify([instanceId, sessionID, generation]) + // Refresh on terminal state/undo/explicit content changes, not every token. + sessionState() + const revision = getOpenCodeMutationRevision(instanceId, sessionID) + retry() + if (key !== identity) { identity = key; setEntries([]); setError("") } + if (!props.active()) { setPending(false); return } + const controller = new AbortController() + const current = () => !controller.signal.aborted && key === identity + && generation === getOpenCodeInstanceGeneration(instanceId) + && revision === getOpenCodeMutationRevision(instanceId, sessionID) + setPending(true) + const timer = setTimeout(() => { + void (async () => { + const next: OutlineEntry[] = [] + let cursor: { after: number; through: number } | undefined + do { + const page = await serverApi.fetchSessionOutline(instanceId, sessionID, cursor, controller.signal) + if (!current()) return + if (page.status !== "outline") throw new Error(tGlobal(`session.pruning.${page.reason}`)) + if (cursor && page.cursor && page.cursor.after <= cursor.after) throw new Error(tGlobal("session.pruning.conflict")) + next.push(...page.entries) + cursor = page.cursor ?? undefined + } while (cursor) + if (current()) { setEntries(next); setError("") } + })().catch(failure => { + if (current()) setError(failure instanceof Error ? failure.message : String(failure)) + }).finally(() => { if (current()) setPending(false) }) + }, 150) + onCleanup(() => { clearTimeout(timer); controller.abort() }) + }) + return { entries, pending, error, refresh: () => setRetry(value => value + 1) } +} diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 208a45700..a6d1f8c7a 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -1303,6 +1303,17 @@ describe("session request authority", () => { const instanceId = "bounded-complete-newer-path", sessionId = "session" const { client, cleanup } = setup(instanceId) const latestPage = 40 + const originalHistoryWindow = serverApi.fetchHistoryWindow + const directRequests: string[] = [] + serverApi.fetchHistoryWindow = async (_instance, _session, target) => { + assert.equal(target.kind, "after") + if (!("messageID" in target)) throw new Error("Missing anchor") + directRequests.push(target.messageID) + const page = Number(target.messageID.slice(5)) + 1 + return { status: "window", messages: [{ ...apiMessage(`page-${page}`), type: "assistant" }], + older: { kind: "before", messageID: `page-${page}` }, newer: page < latestPage ? { kind: "after", messageID: `page-${page}` } : null, + resume: target, latest: page === latestPage } + } ;(client as any).message = { list: async (input: any) => { if (!input.cursor) return { data: [apiMessage(`page-${latestPage}`)], cursor: { next: `c${latestPage - 1}` } } const page = Number(input.cursor.slice(1)) @@ -1320,7 +1331,9 @@ describe("session request authority", () => { assert.ok(visited.length <= latestPage) } assert.deepEqual(visited, Array.from({ length: latestPage }, (_, index) => `page-${index + 1}`)) + assert(directRequests.length > 0, "discarded native cursor paths use direct anchors instead of replaying history") } finally { + serverApi.fetchHistoryWindow = originalHistoryWindow cleanup() } }) diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 4fb4d4398..35a348782 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -92,6 +92,7 @@ import { loadNewerMessageWindow, loadLatestMessageWindow, loadOldestMessageWindow, + loadMessageAnchor, isLatestMessageWindow, clearSessionListRequestState, clearSessionCatalogState, @@ -168,6 +169,7 @@ export { loadNewerMessageWindow, loadLatestMessageWindow, loadOldestMessageWindow, + loadMessageAnchor, isLatestMessageWindow, loadMoreSessions, loadAllSessions, diff --git a/packages/ui/src/styles/components/history-search.css b/packages/ui/src/styles/components/history-search.css index 879b4197d..31be5d5a8 100644 --- a/packages/ui/src/styles/components/history-search.css +++ b/packages/ui/src/styles/components/history-search.css @@ -45,3 +45,18 @@ overflow-wrap: anywhere; font-size: 0.75rem; } + +.history-navigation-status { + position: absolute; + inset-block-start: 0.5rem; + inset-inline-start: 0.5rem; + z-index: 10; + display: flex; + align-items: center; + gap: 0.5rem; + max-width: calc(100% - 1rem); + padding: 0.25rem 0.5rem; + background: var(--surface-secondary); + border: 1px solid var(--border-base); + font-size: 0.75rem; +} diff --git a/packages/ui/src/styles/messaging.css b/packages/ui/src/styles/messaging.css index 08687e825..2c877d8f1 100644 --- a/packages/ui/src/styles/messaging.css +++ b/packages/ui/src/styles/messaging.css @@ -7,6 +7,7 @@ @import "./messaging/delete-overlays.css"; @import "./messaging/technical-groups.css"; @import "./messaging/message-timeline.css"; +@import "./messaging/timeline-scrollbar.css"; @import "./messaging/session-timeline-rail.css"; @import "./messaging/tool-call.css"; @import "./messaging/log-view.css"; diff --git a/packages/ui/src/styles/messaging/message-timeline.css b/packages/ui/src/styles/messaging/message-timeline.css index 3659e936b..3debeb916 100644 --- a/packages/ui/src/styles/messaging/message-timeline.css +++ b/packages/ui/src/styles/messaging/message-timeline.css @@ -56,16 +56,11 @@ padding: 0; overflow-y: auto; overflow-x: visible; - scrollbar-width: none; border-radius: 0; background-color: transparent; box-shadow: none; } -.message-timeline::-webkit-scrollbar { - display: none; -} - .message-timeline-segment { width: 100%; min-height: 1.25rem; @@ -119,6 +114,7 @@ .message-timeline-label { position: relative; z-index: 3; + flex: none; } .message-timeline-segment.message-timeline-segment-hidden { @@ -185,6 +181,7 @@ } .message-timeline-icon { + flex: none; width: 12px; height: 12px; } diff --git a/packages/ui/src/styles/messaging/timeline-scrollbar.css b/packages/ui/src/styles/messaging/timeline-scrollbar.css new file mode 100644 index 000000000..702e69ff0 --- /dev/null +++ b/packages/ui/src/styles/messaging/timeline-scrollbar.css @@ -0,0 +1,40 @@ +/* Reserve the same space before overflow and during hover/focus. Symmetric + gutters keep the segment axis centered; only thumb contrast changes. */ +:root body .message-timeline { + scrollbar-gutter: stable both-edges; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent !important; +} + +:root body .message-timeline:hover, +:root body .message-timeline:focus-within { + scrollbar-color: var(--scrollbar-thumb-hover) transparent !important; +} + +@supports selector(::-webkit-scrollbar) { + :root body .message-timeline, + :root body .message-timeline:hover, + :root body .message-timeline:focus-within { + scrollbar-width: auto; + scrollbar-color: auto !important; + } + + :root body .message-timeline::-webkit-scrollbar { + width: calc(var(--scrollbar-size) / 2); + } + + :root body .message-timeline::-webkit-scrollbar-track { + background: transparent; + } + + :root body .message-timeline::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + border: 0; + border-radius: 0; + } + + :root body .message-timeline:hover::-webkit-scrollbar-thumb, + :root body .message-timeline:focus-within::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb-hover); + } +} diff --git a/packages/ui/tests/browser/fixtures/history-navigation-data.ts b/packages/ui/tests/browser/fixtures/history-navigation-data.ts new file mode 100644 index 000000000..00c72f7dd --- /dev/null +++ b/packages/ui/tests/browser/fixtures/history-navigation-data.ts @@ -0,0 +1,3 @@ +export const navigationMessageId = (index: number) => `msg_${String(index).padStart(5, "0")}` +export const navigationMessage = (index: number) => ({ id: navigationMessageId(index), type: "user" as const, + time: { created: index + 1 }, text: `Passage ${index}\n\n${"Navigation history content. ".repeat(5 + index % 7)}` }) diff --git a/packages/ui/tests/browser/fixtures/history-navigation.tsx b/packages/ui/tests/browser/fixtures/history-navigation.tsx new file mode 100644 index 000000000..513b35ece --- /dev/null +++ b/packages/ui/tests/browser/fixtures/history-navigation.tsx @@ -0,0 +1,72 @@ +import { createSignal, Show } from "solid-js" +import { render } from "solid-js/web" +import SessionView from "../../../src/components/session/session-view" +import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences" +import { setSessionSearchOpen } from "../../../src/stores/session-search" +import { I18nProvider } from "../../../src/lib/i18n" +import { ThemeProvider } from "../../../src/lib/theme" +import { sdkManager } from "../../../src/lib/sdk-manager" +import { serverApi } from "../../../src/lib/api-client" +import { addInstance } from "../../../src/stores/instances" +import { sessions, setSessions, setActiveSession, setProviders } from "../../../src/stores/session-state" +import { messageStoreBus } from "../../../src/stores/message-v2/bus" +import { sseManager } from "../../../src/lib/sse-manager" +import { loadMessages, loadLatestMessageWindow } from "../../../src/stores/session-api" +import { navigationMessage, navigationMessageId } from "./history-navigation-data" +import "../../../src/index.css" + +const instanceId = "navigation", sessionId = "s", count = 1500 +const model = { providerID: "fixture", id: "fixture" } +const assistantId = "msg_streaming" +let nativeLists = 0, time = 10000, live = "", streaming = false +let config = { settings: { locale: "en", showMessageTimeline: true } } +serverApi.fetchConfigOwner = async () => config as any +serverApi.patchConfigOwner = async (_owner, patch: any) => (config = { ...config, ...patch, settings: { ...config.settings, ...patch.settings } }) as any +serverApi.fetchStateOwner = async () => ({} as any) +const emit = (type: string, data: any) => (sseManager as any).handleEvent(instanceId, { + id: `event_${++time}`, type, created: time, location: { directory: "/fixture" }, data: { sessionID: sessionId, ...data }, +}) +const client: any = { + session: { active: async () => ({}), inbox: { list: async () => ({ data: [] }) }, + get: async () => ({ id: sessionId, title: "Long navigation", location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }), + instructions: { entry: { remove: async () => {}, put: async () => {} } }, + }, + model: { default: async () => ({ data: model }) }, + message: { list: async ({ cursor, limit = 200 }: any = {}) => { + nativeLists++ + const end = cursor ? Number(cursor) : count + const start = Math.max(0, end - limit + (streaming && !cursor ? 1 : 0)) + const data: any[] = Array.from({ length: end - start }, (_, index) => navigationMessage(start + index)) + if (streaming && !cursor) data.push({ id: assistantId, type: "assistant", agent: "build", model, time: { created: 10000 }, content: [{ type: "text", text: live }] }) + return { data: data.reverse(), cursor: start ? { next: String(start) } : {} } + } }, +} +;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) +addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client }) +setSessions(previous => new Map(previous).set(instanceId, new Map([[sessionId, { + id: sessionId, instanceId, parentId: null, title: "Long navigation", agent: "build", model: { providerId: "fixture", modelId: "fixture" }, + status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true, version: "1", projectID: "p", location: { directory: "/fixture" }, + cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, +}]]))) +setProviders(previous => new Map(previous).set(instanceId, [{ id: "fixture", name: "Fixture", models: [{ id: "fixture", name: "Fixture", providerId: "fixture", limit: { context: 10000, output: 1000 }, cost: { input: 0, output: 0 } }] }])) +setActiveSession(instanceId, sessionId) +const [visible, setVisible] = createSignal(true) +const store = messageStoreBus.getOrCreate(instanceId) +render(() => + +, document.getElementById("root")!) +;(window as any).fixture = { + id: navigationMessageId, + openSearch: () => { updatePreferences({ locale: "en" }); setSessionSearchOpen(instanceId, sessionId, true) }, + reload: () => loadMessages(instanceId, sessionId, { force: true }), + latest: () => loadLatestMessageWindow(instanceId, sessionId), + switchAway: () => { setActiveSession(instanceId, "other"); setVisible(false) }, + return: () => { setActiveSession(instanceId, sessionId); setVisible(true) }, + stream: (delta: string) => { + if (!streaming) { streaming = true; emit("session.step.started", { assistantMessageID: assistantId, agent: "build", model }); emit("session.text.started", { assistantMessageID: assistantId }) } + live += delta + emit("session.text.delta", { assistantMessageID: assistantId, ordinal: 0, delta }) + }, + snapshot: () => ({ nativeLists, ids: store.getSessionMessageIds(sessionId), window: store.getMessageWindow(sessionId), + scroll: store.getScrollSnapshot(sessionId, "message-stream"), model: sessions().get(instanceId)?.get(sessionId)?.model }), +} diff --git a/packages/ui/tests/browser/history-navigation.test.ts b/packages/ui/tests/browser/history-navigation.test.ts new file mode 100644 index 000000000..6525c0d55 --- /dev/null +++ b/packages/ui/tests/browser/history-navigation.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert/strict" +import { after, before, test } from "node:test" +import { fileURLToPath } from "node:url" +import { mkdir } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { DatabaseSync } from "node:sqlite" +import { chromium, type Browser, type Page } from "playwright" +import { createServer, type ViteDevServer } from "vite" +import solid from "vite-plugin-solid" +import { readNavigationWindow, readSessionOutline } from "../../../server/src/opencode/session-pruning/navigation-store" +import { navigationMessage, navigationMessageId } from "./fixtures/history-navigation-data" + +let server: ViteDevServer, browser: Browser, url: string +before(async () => { + server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", + plugins: [solid(), { name: "navigation-fixture", configureServer(server) { + server.middlewares.use("/fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await server.transformIndexHtml("/fixture", '
')) + }) + } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, server: { host: "127.0.0.1", port: 0, hmr: false, watch: null } }) + await server.listen() + url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) +}) +after(async () => { await browser?.close(); await server?.close() }) + +async function fixture() { + const page = await browser.newPage({ viewport: { width: 1200, height: 800 } }) + const db = new DatabaseSync(":memory:") + db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT); + CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT); + CREATE UNIQUE INDEX seq_idx ON session_message(session_id,seq); + INSERT INTO session_v2 VALUES ('s','/fixture','p',NULL,NULL);`) + const insert = db.prepare("INSERT INTO session_message VALUES (?,'s','user',?,?)") + for (let index = 0; index < 1500; index++) { + const { id, type: _type, ...data } = navigationMessage(index) + insert.run(id, index, JSON.stringify(data)) + } + const errors: string[] = [], windows: any[] = [] + const reads = new Set>() + let hold: { messageID: string; release: () => void; promise: Promise } | undefined + page.on("pageerror", error => errors.push(error.message)) + await page.route("**/api/**", async route => { + const request = route.request(), method = request.url().split("/").at(-1) + if (!request.url().includes("/session-history/") || !["outline", "window"].includes(method!)) { + return route.fulfill({ contentType: "application/json", body: "{}" }) + } + const input = request.postDataJSON() + const scope = { directory: "/fixture", projectID: "p", sessionID: "s" } + const read = method === "window" + ? readNavigationWindow(db, scope, input.target, new AbortController().signal) + : readSessionOutline(db, scope, input.cursor, new AbortController().signal) + reads.add(read) + const response = await read.finally(() => reads.delete(read)) + if (method === "window") { + windows.push(input.target) + if (input.target.messageID === hold?.messageID) await hold!.promise + } + await route.fulfill({ contentType: "application/json", body: JSON.stringify(response) }).catch(() => {}) + }) + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + await page.locator('.message-timeline[data-segment-count="1500"]').waitFor() + return { page, windows, errors, + hold: (index: number) => { + let release!: () => void + const promise = new Promise(done => { release = done }) + hold = { messageID: navigationMessageId(index), release, promise } + return release + }, + close: async () => { hold?.release(); await page.close(); await Promise.allSettled(reads); db.close() }, + } +} + +async function clickTimeline(page: Page, index: number) { + await page.locator(".message-timeline").evaluate((element, index) => { + element.scrollTop = element.scrollHeight * index / 1500 + }, index) + await page.locator(`.message-timeline-segment[data-message-id="${navigationMessageId(index)}"]`).click() +} +const snapshot = (page: Page) => page.evaluate(() => (window as any).fixture.snapshot()) + +test("global timeline jumps over 1200 messages in one bounded window and restores the selected passage", async () => { + const f = await fixture() + try { + const before = await snapshot(f.page) + assert.equal(before.ids.length, 200) + assert.equal(before.ids[0], navigationMessageId(1300)) + await clickTimeline(f.page, 250) + await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids.includes("msg_00250")) + await f.page.locator('.message-stream [data-message-id="msg_00250"]').first().waitFor() + const jumped = await snapshot(f.page) + assert.equal(f.windows.length, 1) + assert.equal(jumped.nativeLists, before.nativeLists, "jump does not replay native pages") + assert.equal(jumped.ids.length, 200) + assert.equal(jumped.window.kind, "history") + assert.deepEqual(jumped.model, before.model) + await f.page.evaluate(() => (window as any).fixture.reload()) + const restored = await snapshot(f.page) + assert.deepEqual(restored.ids, jumped.ids) + assert.equal(f.windows.length, 2) + await f.page.evaluate(() => (window as any).fixture.stream("Live response while reading the old passage")) + assert.deepEqual((await snapshot(f.page)).ids, jumped.ids, "native streaming does not replace the historical window") + await f.page.evaluate(() => (window as any).fixture.switchAway()) + await f.page.evaluate(() => (window as any).fixture.return()) + await f.page.locator(".message-stream").waitFor() + assert((await snapshot(f.page)).ids.includes(navigationMessageId(250))) + assert.deepEqual(f.errors, []) + const captures = path.join(os.tmpdir(), "opencode"); await mkdir(captures, { recursive: true }) + await f.page.screenshot({ path: path.join(captures, "history-navigation.png") }) + } finally { await f.close() } +}) + +test("latest timeline intent supersedes a slow far jump, including a resident destination", async () => { + const f = await fixture() + try { + const release = f.hold(250) + await clickTimeline(f.page, 250) + await f.page.locator(".history-navigation-status").waitFor() + await clickTimeline(f.page, 1450) + await f.page.locator(".history-navigation-status").waitFor({ state: "hidden" }) + release() + await f.page.waitForTimeout(150) + const state = await snapshot(f.page) + assert(state.ids.includes(navigationMessageId(1450))) + assert(!state.ids.includes(navigationMessageId(250))) + assert.deepEqual(f.errors, []) + } finally { await f.close() } +}) + +test("timeline gutter stays fixed across overflow, hover, focus, RTL and fractional zoom", async () => { + const f = await fixture() + try { + const rail = f.page.locator(".message-timeline") + for (const direction of ["ltr", "rtl"]) for (const zoom of [1, 1.25]) { + await f.page.evaluate(({ direction, zoom }) => { document.documentElement.dir = direction; document.body.style.zoom = String(zoom) }, { direction, zoom }) + const measure = () => rail.evaluate(element => { + const style = getComputedStyle(element) + return { width: element.clientWidth, gutter: element.offsetWidth - element.clientWidth, scrollbar: style.scrollbarWidth, + segment: element.querySelector(".message-timeline-segment")!.getBoundingClientRect().width, + height: element.querySelector(".message-timeline-segment")!.getBoundingClientRect().height, + icon: element.querySelector(".message-timeline-icon")!.getBoundingClientRect().width } + }) + const normal = await measure() + assert.notEqual(normal.scrollbar, "none") + assert(normal.gutter > 0 && normal.gutter <= 10, JSON.stringify({ direction, zoom, normal })) + await rail.hover() + const hover = await measure() + assert.equal(hover.width, normal.width) + assert.equal(hover.segment, normal.segment) + assert.equal(hover.height, normal.height) + assert.equal(hover.icon, normal.icon) + await rail.locator("button").first().focus() + assert.equal((await measure()).width, normal.width) + await rail.evaluate(element => { element.style.overflowY = "hidden" }) + assert.equal((await measure()).width, normal.width, "reserved gutter remains without overflow") + await rail.evaluate(element => { element.style.overflowY = "auto" }) + await rail.evaluate(element => { element.style.scrollbarGutter = "auto"; element.style.setProperty("scrollbar-width", "none", "important") }) + const withoutGutter = await measure() + assert(withoutGutter.segment > normal.segment, "only the surrounding rectangle gives up width to the gutter") + assert.equal(withoutGutter.icon, normal.icon, "icons never scale to fit the narrower rail") + assert.equal(withoutGutter.height, normal.height, "vertical geometry does not scale with width") + await rail.evaluate(element => { element.style.removeProperty("scrollbar-gutter"); element.style.removeProperty("scrollbar-width") }) + } + } finally { await f.close() } +}) + +test("a newer distant destination wins and a hidden session rejects a pending jump", async () => { + const f = await fixture() + try { + const release = f.hold(250) + await clickTimeline(f.page, 250) + await f.page.locator(".history-navigation-status").waitFor() + await clickTimeline(f.page, 900) + await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids.includes("msg_00900")) + release() + await f.page.locator(".history-navigation-status").waitFor({ state: "hidden" }) + assert.equal(f.windows.length, 2) + assert(!(await snapshot(f.page)).ids.includes(navigationMessageId(250))) + const releaseHidden = f.hold(100) + await clickTimeline(f.page, 100) + await f.page.locator(".history-navigation-status").waitFor() + await f.page.evaluate(() => (window as any).fixture.switchAway()) + releaseHidden() + await f.page.waitForTimeout(150) + assert((await snapshot(f.page)).ids.includes(navigationMessageId(900))) + assert.deepEqual(f.errors, []) + } finally { await f.close() } +}) + +test("current-session search clicks and keyboard next use direct distant windows", async () => { + const f = await fixture() + try { + await f.page.route("**/session-history/query", route => route.fulfill({ contentType: "application/json", body: JSON.stringify({ + status: "page", scanned: 1500, tools: 0, reasoning: 0, skipped: 0, candidates: [], cursor: null, + hits: [250, 900].map(index => ({ sessionID: "s", messageID: navigationMessageId(index), role: "user", partIndex: 0, + kind: "text", excerpt: `Search result ${index}` })), + }) })) + const initial = await snapshot(f.page) + await f.page.evaluate(() => (window as any).fixture.openSearch()) + await f.page.getByRole("searchbox").fill("Passage") + await f.page.locator(".history-search-result").first().click() + await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids.includes("msg_00250")) + await f.page.getByRole("searchbox").press("Enter") + await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids.includes("msg_00900")) + assert.deepEqual(f.windows.map(target => target.messageID), [navigationMessageId(250), navigationMessageId(900)]) + assert.equal((await snapshot(f.page)).nativeLists, initial.nativeLists) + assert.deepEqual(f.errors, []) + } finally { await f.close() } +}) diff --git a/scripts/test-session-navigation-native.mjs b/scripts/test-session-navigation-native.mjs new file mode 100644 index 000000000..586484e63 --- /dev/null +++ b/scripts/test-session-navigation-native.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict" + +export async function testSessionNavigationNative({ client, location, locationOptions, template }) { + const seed = await client.session.create({ title: "Isolated long navigation fixture", location }, locationOptions) + const exported = await client.session.export({ sessionID: seed.id }) + await client.session.remove({ sessionID: seed.id }) + const id = index => `msg_navigation_${String(index).padStart(5, "0")}` + const messages = Array.from({ length: 1501 }, (_, index) => ({ + ...structuredClone(template), id: id(index), content: [{ type: "text", text: `Navigation passage ${index}` }], + })) + const session = await client.session.import({ ...exported, messages, location }, locationOptions) + const rpc = async (method, input) => (await client.rpc.call({ rpcID: "codenomad.session-pruning", method, + input: { sessionID: session.id, ...input }, location }, locationOptions)).output + try { + const around = await rpc("window", { target: { kind: "around", messageID: id(1200) } }) + assert.equal(around.status, "window", JSON.stringify(around)) + assert.equal(around.messages.length, 200) + assert.equal(around.messages[80].id, id(1200)) + const native = await client.session.export({ sessionID: session.id }) + assert.deepEqual(around.messages, native.messages.slice(1120, 1320), "SQL reconstruction matches native export exactly") + assert.deepEqual(around.messages[80], await client.session.message.get({ sessionID: session.id, messageID: id(1200) })) + assert.deepEqual(await rpc("window", { target: around.resume }), around) + const prior = await rpc("window", { target: around.older }) + assert(prior.messages.some(message => message.id === around.messages[0].id), "prior window retains the viewport anchor") + const following = await rpc("window", { target: around.newer }) + assert(following.messages.some(message => message.id === around.messages.at(-1).id), "next window retains the viewport anchor") + let cursor, count = 0 + do { + const outline = await rpc("outline", cursor ? { cursor } : {}) + assert.equal(outline.status, "outline") + assert(outline.entries.length <= 256) + count += outline.entries.length + cursor = outline.cursor + } while (cursor) + assert.equal(count, native.messages.length) + console.log("PASS: native 1501-message outline, direct distant windows, overlap and exact native payload/restore parity") + } finally { await client.session.remove({ sessionID: session.id }) } +} diff --git a/scripts/test-session-pruning-native.mjs b/scripts/test-session-pruning-native.mjs index 1f8430003..bd8790f70 100644 --- a/scripts/test-session-pruning-native.mjs +++ b/scripts/test-session-pruning-native.mjs @@ -277,6 +277,8 @@ try { assert(target) const { testSessionHistoryNative } = await import("./test-session-history-native.mjs") await testSessionHistoryNative({ client, location, locationOptions, template: target }) + const { testSessionNavigationNative } = await import("./test-session-navigation-native.mjs") + await testSessionNavigationNative({ client, location, locationOptions, template: target }) if (ui) { const { testPruningUI } = await import("./test-session-pruning-ui.mjs") await testPruningUI({ client, connection, baseUrl, root, location, busy: async (sessionID) => {