mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-19 15:34:12 +00:00
feat(history): add global timeline and direct message-window navigation
Extend the existing history-query plugin with bounded outline and anchor-window reads so timeline and current-session search can reach distant passages without loading intervening transcript pages. Share daemon-storage identity binding, preserve location/project/revert authorization, enforce payload budgets and retain overlapping neighbors for reading-position restoration. Route timeline, search and missing restoration anchors through the existing message-load authority fences. Keep the visible transcript during navigation, let the latest requested destination win, reject late results after cancellation or leaving the session, and retain historical reading positions during live generation. Restore a discreet timeline scrollbar with stable reserved geometry. Only marker rectangles narrow; icon dimensions and vertical spacing remain fixed across hover, focus, RTL and fractional zoom. Include localized loading/retry UI and document the navigation contract. Validated with server/plugin/route regressions, 75 UI store tests, 56 distinct Chromium cases, server/UI typechecks, UI/plugin builds and an isolated Windows OpenCode 2.0.5 fixture comparing 1501-message navigation against native export and message reads. Installed desktop and other-platform validation remain separate.
This commit is contained in:
parent
03e7c47a2d
commit
5b988eca12
43 changed files with 1123 additions and 82 deletions
|
|
@ -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
|
||||
|
|
|
|||
69
dev-docs/SESSION_HISTORY_NAVIGATION.md
Normal file
69
dev-docs/SESSION_HISTORY_NAVIGATION.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<T>(ctx: Plugin.Context, sessionID: string | undefined, signal: AbortSignal,
|
||||
read: (db: DatabaseSync, scope: HistoryScope) => Promise<T>): Promise<T | Blocked> {
|
||||
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(() => {}) }
|
||||
}
|
||||
|
|
@ -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<HistoryNativeResult> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<typeof navigationTargetSchema>
|
||||
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<typeof outlineEntrySchema>
|
||||
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<typeof outlineResultSchema>
|
||||
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<typeof navigationWindowResultSchema>
|
||||
|
|
@ -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<NavigationWindowResult> {
|
||||
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<OutlineResult> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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() }
|
||||
})
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
45
packages/server/src/server/routes/session-navigation.test.ts
Normal file
45
packages/server/src/server/routes/session-navigation.test.ts
Normal file
|
|
@ -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() }
|
||||
})
|
||||
36
packages/server/src/server/routes/session-navigation.ts
Normal file
36
packages/server/src/server/routes/session-navigation.ts
Normal file
|
|
@ -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<HistoryRouteDeps, "workspaceManager">) {
|
||||
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) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
onLoadLatestMessages?: (signal?: AbortSignal) => Promise<void>
|
||||
onLoadOldestMessages?: (signal?: AbortSignal) => Promise<void>
|
||||
onLoadMessageAnchor?: (messageId: string, signal?: AbortSignal) => Promise<void>
|
||||
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<Set<string>>(new Set())
|
||||
|
|
@ -306,7 +315,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
const searchFailed = createMemo(() => hasMessageSearchAuthority(trimmedSearchQuery(), failedSearchQuery()))
|
||||
|
||||
const timelineSegmentCache = new Map<string, { revision: number; status: string; locale: string; signature: string; segments: TimelineSegment[] }>()
|
||||
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"}
|
||||
>
|
||||
<SessionCleanupProgress instanceId={props.instanceId} sessionId={props.sessionId} />
|
||||
<Show when={navigationPending()}>
|
||||
<div class="history-navigation-status window-toolbar" role="status">
|
||||
<span>{t("history.navigation.loading")}</span>
|
||||
<button type="button" class="button-tertiary" onClick={cancelWindowNavigation}>{t("alertDialog.actions.cancel")}</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={outline.error() && !navigationPending() && showMessageTimelinePreference()}>
|
||||
<div class="history-navigation-status window-toolbar" role="status">
|
||||
<span title={outline.error()}>{t("history.navigation.outlineUnavailable")}</span>
|
||||
<button type="button" class="button-tertiary" onClick={outline.refresh}>{t("messageSection.search.retry")}</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class={`message-layout${showTimeline() && !props.timelineMount ? " message-layout--with-timeline" : ""}`}
|
||||
data-scroll-buttons={scrollButtonsCount()}
|
||||
|
|
@ -1114,6 +1188,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
persistMessageScrollSnapshot({ snapshot })
|
||||
}}
|
||||
onUserReachedTop={() => { 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?.()}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
|
|
@ -1384,7 +1459,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
<div class="history-search-results">
|
||||
<For each={currentSearchMatches()}>{(match, index) => (
|
||||
<button type="button" class="history-search-result" classList={{ "history-search-result-active": activeSearchIndex() === index() }}
|
||||
onClick={() => { setActiveSearchIndex(index()); setPreviewSearchMatch(match) }}>
|
||||
onClick={() => {
|
||||
setActiveSearchIndex(index())
|
||||
navigateSearchMatch(match)
|
||||
}}>
|
||||
<span class="text-xs text-muted">{match.sessionId} · {match.messageId} · {t(`history.${match.partType}`)}</span>
|
||||
<span>{match.preview}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -452,8 +452,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (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<MessageTimelineProps> = (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<MessageTimelineProps> = (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<MessageTimelineProps> = (props) => {
|
|||
onMouseEnter={() => clearCloseTimer()}
|
||||
onMouseLeave={() => scheduleClose()}
|
||||
>
|
||||
<MessagePreview
|
||||
<Show when={data().resident} fallback={<p>{data().summary}</p>}><MessagePreview
|
||||
messageId={data().messageId}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
/>
|
||||
/></Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
|
|
|
|||
28
packages/ui/src/components/session-outline-projection.ts
Normal file
28
packages/ui/src/components/session-outline-projection.ts
Normal file
|
|
@ -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, unknown>) => string): TimelineSegment[] {
|
||||
const byMessage = new Map<string, TimelineSegment[]>()
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
@ -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<SessionViewProps> = (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)}
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
|||
|
||||
|
||||
export const serverApi = {
|
||||
fetchHistoryWindow(instanceId: string, sessionID: string, target: NavigationTarget, signal?: AbortSignal): Promise<NavigationWindowResult> {
|
||||
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<OutlineResult> {
|
||||
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<HistoryResult> {
|
||||
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/session-history/query`, {
|
||||
method: "POST", body: JSON.stringify(input), signal,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const messagingMessages = {
|
||||
"history.navigation.loading": "טוען קטע…",
|
||||
"history.navigation.outlineUnavailable": "ציר הזמן המלא אינו זמין",
|
||||
"history.scope": "היקף החיפוש",
|
||||
"history.session": "כל השיחה",
|
||||
"history.workspace": "כל סביבת העבודה",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const messagingMessages = {
|
||||
"history.navigation.loading": "該当箇所を読み込み中…",
|
||||
"history.navigation.outlineUnavailable": "全体のタイムラインを取得できません",
|
||||
"history.scope": "検索範囲",
|
||||
"history.session": "セッション全体",
|
||||
"history.workspace": "ワークスペース全体",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const messagingMessages = {
|
||||
"history.navigation.loading": "अंश लोड हुँदैछ…",
|
||||
"history.navigation.outlineUnavailable": "पूरा टाइमलाइन उपलब्ध छैन",
|
||||
"history.scope": "खोजको दायरा",
|
||||
"history.session": "पूरै सत्र",
|
||||
"history.workspace": "पूरै कार्यस्थान",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const messagingMessages = {
|
||||
"history.navigation.loading": "Загрузка фрагмента…",
|
||||
"history.navigation.outlineUnavailable": "Полная шкала времени недоступна",
|
||||
"history.scope": "Область поиска",
|
||||
"history.session": "Вся сессия",
|
||||
"history.workspace": "Всё рабочее пространство",
|
||||
|
|
|
|||
|
|
@ -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ı",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
export const messagingMessages = {
|
||||
"history.navigation.loading": "正在加载所选段落…",
|
||||
"history.navigation.outlineUnavailable": "完整时间线暂不可用",
|
||||
"history.scope": "搜索范围",
|
||||
"history.session": "整个会话",
|
||||
"history.workspace": "整个工作区",
|
||||
|
|
|
|||
28
packages/ui/src/stores/history-window.ts
Normal file
28
packages/ui/src/stores/history-window.ts
Normal file
|
|
@ -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] },
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof planMessageWindowLoad> = 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<string>()
|
||||
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<string>()
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
55
packages/ui/src/stores/session-outline.ts
Normal file
55
packages/ui/src/stores/session-outline.ts
Normal file
|
|
@ -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<string>; sessionId: Accessor<string>; active: Accessor<boolean> }) {
|
||||
const [entries, setEntries] = createSignal<OutlineEntry[]>([])
|
||||
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) }
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ import {
|
|||
loadNewerMessageWindow,
|
||||
loadLatestMessageWindow,
|
||||
loadOldestMessageWindow,
|
||||
loadMessageAnchor,
|
||||
isLatestMessageWindow,
|
||||
clearSessionListRequestState,
|
||||
clearSessionCatalogState,
|
||||
|
|
@ -168,6 +169,7 @@ export {
|
|||
loadNewerMessageWindow,
|
||||
loadLatestMessageWindow,
|
||||
loadOldestMessageWindow,
|
||||
loadMessageAnchor,
|
||||
isLatestMessageWindow,
|
||||
loadMoreSessions,
|
||||
loadAllSessions,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
40
packages/ui/src/styles/messaging/timeline-scrollbar.css
Normal file
40
packages/ui/src/styles/messaging/timeline-scrollbar.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}` })
|
||||
72
packages/ui/tests/browser/fixtures/history-navigation.tsx
Normal file
72
packages/ui/tests/browser/fixtures/history-navigation.tsx
Normal file
|
|
@ -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(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}>
|
||||
<SessionView sessionId={sessionId} activeSessions={sessions().get(instanceId)!} instanceId={instanceId} instanceFolder="/fixture" escapeInDebounce={false} isActive={true} />
|
||||
</Show></ThemeProvider></I18nProvider></ConfigProvider>, 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 }),
|
||||
}
|
||||
212
packages/ui/tests/browser/history-navigation.test.ts
Normal file
212
packages/ui/tests/browser/history-navigation.test.ts
Normal file
|
|
@ -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", '<html><body><div id="root" style="display:flex;width:1100px;height:740px"></div><script type="module" src="/tests/browser/fixtures/history-navigation.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], 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<Promise<unknown>>()
|
||||
let hold: { messageID: string; release: () => void; promise: Promise<void> } | 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<void>(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() }
|
||||
})
|
||||
38
scripts/test-session-navigation-native.mjs
Normal file
38
scripts/test-session-navigation-native.mjs
Normal file
|
|
@ -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 }) }
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue