mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
fix(history): recover stale restoration and prioritize active session loads
Large-session desktop switching reproduced repeated restoration requests for the same absent anchor. Distinguish anchor_missing in the bounded native window RPC from ownership and revert conflicts. Automatic restoration recovers once through a fresh visible latest page and replaces the stale saved cursor only after success; explicit navigation and authority failures retain their error semantics. Cancel view-owned initial reads when hidden, release their request authority before an immediate return, and prevent obsolete hydration callbacks from reviving after away-and-back navigation. Pause outline scans while transcript hydration is loading, preserving their accepted cursor and completed display. Add the user-approved lightweight timeline scrollbar separator, hidden when its measured content does not overflow. Record the real Windows desktop baseline and remaining priorities in SESSION_HISTORY_STRESS_REVIEW.md: 62.62 MiB of transcript responses, repeated outline scans, transient heap growth and main-thread stalls. Separate observations from unproven leak or CPU attribution, and document the live-event merge, incremental metadata and byte-budget work still needed. Validate 18 browser navigation cases (17 in the complete run and the corrected localized conflict assertion in a targeted rerun), four active-load lifecycle tests, seven navigation store/routes tests and both typechecks. The complete isolated native Windows OpenCode 2.0.5 UI fixture passes, including missing-anchor RPC semantics and native outline/window parity.
This commit is contained in:
parent
5cb6f0ebfa
commit
0636301fd6
16 changed files with 273 additions and 16 deletions
|
|
@ -23,7 +23,7 @@ where their authority, lifetime or runtime differs.
|
|||
|
||||
| Area | Current behaviour | Implementation |
|
||||
| --- | --- | --- |
|
||||
| Session outline | Four recent metadata-only snapshots; paused cursor survives hidden/unmounted views, completed rail is reused immediately, and status/message changes refresh without clearing it. Instance generation, mutation revision and undo boundary fence reuse. Initial progress is visible. | `packages/ui/src/stores/session-outline.ts` |
|
||||
| Session outline | Four recent metadata-only snapshots; paused cursor survives hidden/unmounted views and transcript hydration, completed rail is reused immediately, and status/message changes refresh without clearing it. Instance generation, mutation revision and undo boundary fence reuse. Initial progress is visible. | `packages/ui/src/stores/session-outline.ts` |
|
||||
| Providers/models | Retained signals; shared in-flight catalogue load; dirty-bit trailing refresh; instance, location and request-generation checks. No completed-result TTL inside `fetchProviders` itself. | `packages/ui/src/stores/session-api.ts` |
|
||||
| Git changes | Filesystem events debounce for 100 ms; one passive refresh plus a pending follow-up; hidden tab marked stale; request versions protect status/diff. Server shares concurrent status requests, not completed results. | `useGitChanges.ts`, `filesystem-events.ts`, server `workspaces/git-status.ts` |
|
||||
| Worktree display | Last successful server snapshot; demand-driven refresh after 10 s or invalidation; one scan per workspace; obsolete scans discarded and followed by validation; UI requests coalesced. | server `workspaces/worktree-inventory.ts`, UI `stores/worktrees.ts` |
|
||||
|
|
|
|||
|
|
@ -80,6 +80,14 @@ resident and historical markers. They never mount a message card or load message
|
|||
payloads. The opaque preview surface wraps text and stays inside the viewport;
|
||||
Escape, rail scrolling, and viewport resizing dismiss it.
|
||||
|
||||
An absent/hidden native anchor returns `anchor_missing`, separately from ownership
|
||||
or revert conflicts. Automatic restoration recovers once through the latest visible
|
||||
page and replaces its saved anchor/cursor only on success. Explicit navigation still
|
||||
reports the missing destination. Active-view initial reads cancel on hiding, and
|
||||
outline work pauses while transcript hydration is loading. See
|
||||
`SESSION_HISTORY_STRESS_REVIEW.md` for measured desktop switching costs and the
|
||||
remaining streaming/memory work.
|
||||
|
||||
Outline scans retain accepted pages and their sequence-horizon cursor when a view
|
||||
is hidden or unmounted. Four recent metadata snapshots are retained in the renderer,
|
||||
keyed by instance/session, connection generation, content-mutation revision and undo
|
||||
|
|
|
|||
91
dev-docs/SESSION_HISTORY_STRESS_REVIEW.md
Normal file
91
dev-docs/SESSION_HISTORY_STRESS_REVIEW.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Large-session switching: measured baseline and follow-up
|
||||
|
||||
## Scope and method — 2026-09-20
|
||||
|
||||
This is a desktop baseline for PR #723, not a claim that session streaming or
|
||||
memory use is fully qualified. Windows Tauri `0.20.0-dev-20260920-5cb6f0eb` was
|
||||
running the installed UI with the timeline-divider CSS update only. The behavior
|
||||
fixes described below were not present during this capture.
|
||||
|
||||
Three real sessions were selected using native renderer input: the active long
|
||||
integration conversation (approximately 11,000 stored messages / 13,983 timeline
|
||||
markers initially), an already-warm issues conversation (11,714 markers), and a
|
||||
review conversation (4,836 stored messages). Marker counts include content groups
|
||||
and are not message counts. The integration conversation continued streaming.
|
||||
|
||||
Sequence: two rounds through all three sessions (sample at 300 ms and 5 s), then
|
||||
six switches at roughly 400 ms intervals, 10 s settling, and one explicit renderer
|
||||
garbage collection. The capture lasted approximately 45 s. It observed application
|
||||
requests, DOM counts, JS heap usage and browser long tasks; it did not modify
|
||||
messages or query the shared database externally. No credentials or message bodies
|
||||
are included here. Native fixture tests use separate synthetic storage/daemons.
|
||||
|
||||
## Observations
|
||||
|
||||
| Area | Measured result | Interpretation / limit |
|
||||
| --- | --- | --- |
|
||||
| Saved passage | Three completed window requests for the same old anchor returned HTTP 200 with `blocked/conflict`; a fourth was cancelled. Reload controls reappeared on successive returns. | A saved anchor can outlive its message. Treating that as a generic conflict makes retry repeat the same impossible destination. |
|
||||
| Warm return | The issues session had 32 rendered rows and 11,714 markers at the first 300 ms sample on each return. The integration rail also retained its complete outline. | Outline retention works for a completed snapshot; it does not fix transcript restoration. This is sampled availability, not a measured first-paint latency. |
|
||||
| Cold outline | The review session had no rows/markers at 300 ms, then 16 rows, 338 resident markers and `2048 / 4836` outline progress at 5 s. A later visit resumed at 2048 and reached 2816. | Resume preserves accepted pages, but a short visit still presents only the resident rail until the entire outline is ready. |
|
||||
| Outline traffic | 105 requests, 4.22 MiB completed response bytes. 90 requests belonged to the integration session, including two first pages. Six outline requests were cancelled. Slowest completed outline request: 4.53 s. | Full-history refreshes remain expensive even though individual pages are bounded. Retention and virtualization do not make refresh incremental. |
|
||||
| Transcript traffic | 14 native message-list requests, 62.62 MiB completed response bytes. The integration session accounted for 42.17 MiB; the others for 7.85 and 12.60 MiB. None was cancelled in this trace. | Inspect load provenance, SDK reconciliation and view lifecycle together. The trace does not prove every list request was redundant or originated in the active-view hook. |
|
||||
| JS memory | Initial sample 228.1 MiB; sampled peak 513.9 MiB; after explicit GC 216.3 MiB. | Significant transient allocation. The initial sample was not GC-normalized, so this neither proves nor disproves a retained-memory leak. These are renderer JS heap figures, not total desktop RSS. |
|
||||
| Main-thread stalls | 39 tasks over 50 ms, 3,659 ms cumulative duration; p95 129 ms, maximum 514 ms. | Noticeable jank is measurable. A CPU allocation profile is still needed to attribute it among normalization, projection, layout and rendering. |
|
||||
| DOM | Samples ranged from 3,076 to 5,585 elements across the window; active transcript samples had 0–32 mounted rows. | Rendering remains bounded in this short run. Bounded DOM does not bound parsed payloads, SDK stores or cached metadata. |
|
||||
|
||||
The capture file and profiling helpers are local diagnostics in the approved
|
||||
temporary directory (`pr723-switch-profile.json`); they contain local session
|
||||
identities and are not repository fixtures. Another run will differ with machine
|
||||
load, message sizes, cached views, expanded tools and ongoing generation.
|
||||
|
||||
## Narrow corrections in this follow-up
|
||||
|
||||
1. **Distinguish absent anchors from authority conflicts.** The bounded native
|
||||
window operation reports `anchor_missing` only after session ownership/visibility
|
||||
has been checked and the anchor is absent from that session's visible rows.
|
||||
Route-level location/project/revert revalidation still wins over this result.
|
||||
2. **Recover a stale saved position once.** Automatic session/window restoration
|
||||
loads a fresh visible latest page after `anchor_missing`. Its successful commit
|
||||
retires the dead anchor and cursor, so subsequent visits do not repeat it.
|
||||
Explicit timeline/search jumps still report missing destinations. Transport and
|
||||
ownership conflicts keep their retry semantics and never authorize this fallback.
|
||||
3. **Cancel the initial view-owned read on hiding/disposal.** Its load authority is
|
||||
released synchronously so an immediate return can issue a replacement before the
|
||||
old promise settles. Successful loads retain their loaded flag. An away-and-back
|
||||
cycle during hydration cannot revive an obsolete callback for the same session.
|
||||
4. **Prioritize transcript hydration over outline scanning.** A loading active
|
||||
transcript pauses its outline scan; accepted metadata/cursor and the previous
|
||||
completed rail remain retained. This is local priority, not a global scheduler.
|
||||
5. **Timeline scrollbar rule.** A light, non-interactive 1 px separator uses the
|
||||
Status card contour color and appears only when the measured rail overflows.
|
||||
It follows the content edge without assuming a platform scrollbar width.
|
||||
|
||||
## Remaining work, ordered by user impact
|
||||
|
||||
| Priority | Work | Evidence / risk | Acceptance check |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 | Merge live events with latest-window reads instead of requiring a quiet interval | `loadMessages` rejects a latest snapshot whenever message revisions changed during the read, with three bounded retries. Sustained generation can exhaust that policy. This starvation risk is from code inspection, not a separately isolated failure in the desktop trace. | Delay a large latest page while emitting real native deltas; publish current content without lost/duplicated text, unbounded retries or false empty state. |
|
||||
| P1 | Incremental/coalesced outline refresh | Two first pages and 90 outline requests for the streaming session in one short run. Current status/message-driven refresh still scans the prefix. | Track append horizon and dirty existing entries; fetch changes without rescanning all history. Destructive mutation/revert/location fences must remain authoritative. |
|
||||
| P1 | Attribute repeated native lists | 62.62 MiB in 14 list responses, including warm sessions. Several consumers exist: view opening, native SDK reconciliation, rotation and restoration. | Tag request initiators and repeat cold/warm/rapid switches; coalesce only equivalent reads and prioritize foreground destinations. |
|
||||
| P2 | Reduce outline parsing and per-page overhead | The wire outline is small, but `navigation-store.ts` reads/parses message JSON, yields, and each page repeats the authenticated storage binding and ownership checks. | CPU/I/O profile of large tool-heavy history; bounded metadata projection with measured cancellation latency and no weakened ownership checks. |
|
||||
| P2 | Byte budgets across caches and stores | UI view cache is five sessions; outline snapshot cache is four keys. Limits are counts, not a total byte budget. Cache eviction does not necessarily release objects still held by mounted owners. | GC-normalized runs beyond both cache limits, multiple instances, eviction and session deletion; document retained bytes and ownership before choosing budgets. |
|
||||
| P2 | Reduce projection allocation during streaming | `session-outline-projection.ts`, timeline layout/maps and reactive segment state traverse metadata collections. A 514 ms long task was observed, but not attributed. | Profile allocations and CPU stacks with 10k/50k markers and sustained deltas; stable marker identities and scrollbar geometry must survive optimization. |
|
||||
| P2 | Improve cold-loading presentation | A 4,836-message session still had only 338 resident markers after 5 s. | Show clear progress while preserving a usable rail. Do not publish growing partial extents that move a manually held scrollbar thumb. |
|
||||
| P2 | Error provenance and recovery | Earlier desktop refresh HTTP 500 was not reproduced in this trace. Some route admission awaits occur outside the navigation handler's catch; errors may lose their cause. | Capture stage/timing/retryability without payloads, distinguish cancellation from failure, and make each retry repeat a recoverable operation. |
|
||||
| Qualification | Soak, constrained links, other hosts | This is one Windows desktop run, not Electron/macOS/Linux/WSL coverage or a leak study. | Slow network, reconnect during initial/anchor reads, cleanup/revert during switching, more than five large sessions, repeat equal-workload GC baselines and process-private bytes over a longer run. |
|
||||
|
||||
## Regression coverage
|
||||
|
||||
- The real SessionView/SQLite-backed browser fixture covers missing saved anchors
|
||||
with a resident window and after eviction, a second return without retrying the
|
||||
dead anchor, and no automatic fallback on an ownership/revert conflict.
|
||||
- The active-load hook tests cover immediate cancellation/authority release,
|
||||
reactivation before promise settlement, successful cache retention and an
|
||||
away-and-back hydration race.
|
||||
- Existing mixed-history navigation cases retain exact extent, native thumb
|
||||
ownership, window overlap, distant jumps and streaming reading-position checks.
|
||||
- The isolated native navigation fixture checks the new absent-anchor result over
|
||||
the real plugin RPC as well as its existing export/window/outline parity checks.
|
||||
|
||||
Test outcomes and installed artifact status are reported separately in the PR;
|
||||
baseline measurements above must not be relabeled as post-fix measurements.
|
||||
|
|
@ -27,7 +27,8 @@ export const outlineResultSchema = z.union([pruningBlockedSchema, z.object({
|
|||
total: z.number().int().nonnegative(), cursor: outlineCursorSchema.nullable(),
|
||||
}).strict()])
|
||||
export type OutlineResult = z.infer<typeof outlineResultSchema>
|
||||
export const navigationWindowResultSchema = z.union([pruningBlockedSchema, z.object({
|
||||
export const navigationWindowResultSchema = z.union([pruningBlockedSchema,
|
||||
z.object({ status: z.literal("blocked"), reason: z.literal("anchor_missing") }).strict(), 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(),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export async function readNavigationWindow(db: DatabaseSync, scope: HistoryScope
|
|||
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" }
|
||||
if (!anchor) return { status: "blocked", reason: "anchor_missing" }
|
||||
sequence = Number(anchor.seq)
|
||||
}
|
||||
const select = (clause: string, args: SQLInputValue[], ascending: boolean, count: number) => db.prepare(
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ test("navigation enforces ownership, message membership and staged undo visibili
|
|||
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" })
|
||||
assert.deepEqual(await readNavigationWindow(db, scope, { kind: "around", messageID: "foreign" }, signal()), { status: "blocked", reason: "anchor_missing" })
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { findHistoryMatches } from "../stores/session-history"
|
|||
import HistoryStatistics from "./history-statistics"
|
||||
import HistoryMessagePreview from "./history-message-preview"
|
||||
import { createSessionOutline } from "../stores/session-outline"
|
||||
import { MissingHistoryAnchorError } from "../stores/history-window"
|
||||
import { sessions } from "../stores/session-state"
|
||||
import { createSessionOutlineProjection } from "./session-outline-projection"
|
||||
import SessionCleanupProgress from "./session-cleanup-progress"
|
||||
|
|
@ -346,7 +347,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
})
|
||||
})
|
||||
const outline = createSessionOutline({ instanceId: () => props.instanceId, sessionId: () => props.sessionId,
|
||||
active: () => props.isActive !== false && Boolean(props.onLoadMessageAnchor) && showMessageTimelinePreference() })
|
||||
active: () => props.isActive !== false && !props.loading && Boolean(props.onLoadMessageAnchor) && showMessageTimelinePreference() })
|
||||
const projectSessionOutline = createSessionOutlineProjection()
|
||||
const timelineSegments = createMemo(() => {
|
||||
const boundary = sessions().get(props.instanceId)?.get(props.sessionId)?.revert?.messageID
|
||||
|
|
@ -643,12 +644,21 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
restoringScrollSnapshot = true
|
||||
const restore = async () => {
|
||||
setOlderMessageLoadFailed(false)
|
||||
let restoredSnapshot = snapshot
|
||||
if (!snapshot.atBottom && snapshot.anchorKey && !visibleMessageIds().includes(snapshot.anchorKey) && (props.onLoadMessageAnchor || props.onLoadMoreMessages)) {
|
||||
try {
|
||||
if (props.onLoadMessageAnchor) {
|
||||
const controller = new AbortController()
|
||||
anchorRestoreController = controller
|
||||
try { await props.onLoadMessageAnchor(snapshot.anchorKey, controller.signal) }
|
||||
try {
|
||||
try { await props.onLoadMessageAnchor(snapshot.anchorKey, controller.signal) }
|
||||
catch (error) {
|
||||
if (!(error instanceof MissingHistoryAnchorError) || !props.onLoadLatestMessages || !isCurrentRestore()) throw error
|
||||
await props.onLoadLatestMessages(controller.signal)
|
||||
if (!isCurrentRestore()) return
|
||||
restoredSnapshot = store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE) ?? snapshot
|
||||
}
|
||||
}
|
||||
finally { if (anchorRestoreController === controller) anchorRestoreController = null }
|
||||
} else await loadPagesUntilAnchor({
|
||||
hasAnchor: () => visibleMessageIds().includes(snapshot.anchorKey!),
|
||||
|
|
@ -668,7 +678,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
if (!isCurrentRestore()) return
|
||||
retryAnchorRestore = null
|
||||
|
||||
api.restoreScrollSnapshot(snapshot, {
|
||||
api.restoreScrollSnapshot(restoredSnapshot, {
|
||||
behavior: "auto",
|
||||
fallback: () => {
|
||||
if (!isCurrentRestore()) return
|
||||
|
|
@ -680,7 +690,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
onApplied: () => {
|
||||
if (!isCurrentRestore()) return
|
||||
restoringScrollSnapshot = false
|
||||
setLastGoodScrollSnapshot(restoreSessionId, snapshot)
|
||||
setLastGoodScrollSnapshot(restoreSessionId, restoredSnapshot)
|
||||
setDidRestoreScroll(true)
|
||||
},
|
||||
onCancelled: () => {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ export default function TimelineVirtualList<T>(props: {
|
|||
<button ref={marker} class="message-timeline-segment" tabIndex={-1} />
|
||||
<div ref={gap} style={{ height: "var(--message-timeline-segment-gap)" }} />
|
||||
</div>
|
||||
<div class="timeline-virtual-content" style={{ height: `${layout().extent}px` }}>
|
||||
<div class="timeline-virtual-content"
|
||||
data-overflow={viewport().height > 0 && Math.round(layout().extent) > viewport().height ? "true" : undefined}
|
||||
style={{ height: `${layout().extent}px` }}>
|
||||
<For each={visible()}>{item => <div class="timeline-virtual-row" style={{
|
||||
top: `${byItem().get(item)!.top}px`, height: `${byItem().get(item)!.height}px`,
|
||||
"padding-top": `${byItem().get(item)!.space}px`,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,59 @@ function deferred<T = void>() {
|
|||
}
|
||||
|
||||
describe("useActiveSessionMessageLoad", () => {
|
||||
it("cancels hidden reads and releases their authority before an immediate return", async () => {
|
||||
const [active, setActive] = createSignal(true)
|
||||
const requests: AbortSignal[] = [], invalidations: number[] = [], errors: unknown[] = []
|
||||
const gate = deferred()
|
||||
let dispose = () => {}
|
||||
createRoot(done => {
|
||||
dispose = done
|
||||
useActiveSessionMessageLoad({ isActive: active, instanceId: () => 'inst', session: () => ({ id: 'a' }),
|
||||
waitForHydration: async () => {}, onError: error => errors.push(error),
|
||||
loadMessages: (_instance, _session, options) => {
|
||||
const index = requests.length
|
||||
requests.push(options!.signal!)
|
||||
options!.registerInvalidation!(() => invalidations.push(index))
|
||||
return gate.promise.then(() => options!.signal!.throwIfAborted())
|
||||
},
|
||||
})
|
||||
})
|
||||
try {
|
||||
await tick()
|
||||
setActive(false)
|
||||
assert.equal(requests[0].aborted, true)
|
||||
assert.deepEqual(invalidations, [0])
|
||||
setActive(true)
|
||||
await tick()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[1].aborted, false)
|
||||
gate.resolve()
|
||||
await tick()
|
||||
setActive(false)
|
||||
assert.deepEqual(invalidations, [0], 'completed snapshots keep their loaded flag')
|
||||
assert.deepEqual(errors, [], 'intentional cancellation is not a load failure')
|
||||
} finally { gate.resolve(); dispose() }
|
||||
})
|
||||
|
||||
it("does not revive the first hydration callback after an away-and-back cycle", async () => {
|
||||
const [active, setActive] = createSignal(true)
|
||||
const gate = deferred(), loads: string[] = []
|
||||
let dispose = () => {}
|
||||
createRoot(done => {
|
||||
dispose = done
|
||||
useActiveSessionMessageLoad({ isActive: active, instanceId: () => 'inst', session: () => ({ id: 'a' }),
|
||||
waitForHydration: () => gate.promise, loadMessages: (_instance, session) => { loads.push(session) } })
|
||||
})
|
||||
try {
|
||||
await tick()
|
||||
setActive(false)
|
||||
setActive(true)
|
||||
gate.resolve()
|
||||
await tick()
|
||||
assert.deepEqual(loads, ['a'])
|
||||
} finally { dispose() }
|
||||
})
|
||||
|
||||
it("loads once on activation, ignores same-id session replacement, and reloads on id change or reactivation", async () => {
|
||||
const loads: Array<{ instanceId: string; sessionId: string }> = []
|
||||
const [session, setSession] = createSignal<{ id: string } | undefined>({ id: "a" })
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
|
||||
/**
|
||||
* Dependencies for {@link useActiveSessionMessageLoad}. Everything is injected
|
||||
|
|
@ -14,7 +14,10 @@ export interface ActiveSessionMessageLoadDeps {
|
|||
/** The current session object (or undefined). Read reactively. */
|
||||
session: () => { id: string } | undefined
|
||||
/** Loads the messages for a session. */
|
||||
loadMessages: (instanceId: string, sessionId: string) => Promise<void> | void
|
||||
loadMessages: (instanceId: string, sessionId: string, options?: {
|
||||
signal?: AbortSignal
|
||||
registerInvalidation?: (invalidate: () => void) => void
|
||||
}) => Promise<void> | void
|
||||
/** Resolves once the instance's workspace metadata has hydrated. */
|
||||
waitForHydration: (instanceId: string) => Promise<void>
|
||||
/** Optional error sink for a rejected load. */
|
||||
|
|
@ -43,13 +46,25 @@ export function useActiveSessionMessageLoad(deps: ActiveSessionMessageLoadDeps):
|
|||
const sessionId = activeSessionId()
|
||||
if (!sessionId) return
|
||||
const instanceId = deps.instanceId()
|
||||
const controller = new AbortController()
|
||||
let invalidate: (() => void) | undefined
|
||||
let settled = false
|
||||
onCleanup(() => {
|
||||
// Release the old request's loading authority synchronously so a rapid
|
||||
// return can start immediately, before the aborted promise settles.
|
||||
if (!settled) invalidate?.()
|
||||
controller.abort()
|
||||
})
|
||||
void Promise.resolve(deps.waitForHydration(instanceId))
|
||||
.then(() => {
|
||||
// Re-check after the async gate: the user may have switched away or to
|
||||
// a different session while metadata was hydrating.
|
||||
if (!deps.isActive() || deps.session()?.id !== sessionId) return
|
||||
return deps.loadMessages(instanceId, sessionId)
|
||||
if (controller.signal.aborted || !deps.isActive() || deps.session()?.id !== sessionId) return
|
||||
return deps.loadMessages(instanceId, sessionId, {
|
||||
signal: controller.signal, registerInvalidation: callback => { invalidate = callback },
|
||||
})
|
||||
})
|
||||
.catch((error) => deps.onError?.(error))
|
||||
.catch((error) => { if (!controller.signal.aborted) deps.onError?.(error) })
|
||||
.finally(() => { settled = true })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ 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:"
|
||||
// Only a confirmed absent/hidden anchor can retire a saved reading position.
|
||||
// Transport failures and ownership/revert conflicts retain retry semantics.
|
||||
export class MissingHistoryAnchorError extends Error {
|
||||
constructor() { super(tGlobal("session.pruning.conflict")) }
|
||||
}
|
||||
export const historyWindowCursor = (target: NavigationTarget) => PREFIX + encodeURIComponent(JSON.stringify(target))
|
||||
export function historyWindowTarget(cursor: string | undefined): NavigationTarget | undefined {
|
||||
if (!cursor?.startsWith(PREFIX)) return undefined
|
||||
|
|
@ -16,7 +21,10 @@ export function historyWindowTarget(cursor: string | undefined): NavigationTarge
|
|||
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}`))
|
||||
if (result.status !== "window") {
|
||||
if (result.reason === "anchor_missing") throw new MissingHistoryAnchorError()
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -6,7 +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 { historyWindowCursor, historyWindowTarget, readHistoryWindow, MissingHistoryAnchorError } 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"
|
||||
|
|
@ -1675,6 +1675,13 @@ async function loadMessages(
|
|||
}
|
||||
} catch (error) {
|
||||
if (options?.signal?.aborted) return
|
||||
if (error instanceof MissingHistoryAnchorError && intent === "open" && isCurrent()) {
|
||||
// A persisted window can outlive its anchor (deletion, compaction, undo).
|
||||
// Recover once through a fresh visible latest page; its successful commit
|
||||
// replaces the stale snapshot. Explicit navigation must still fail visibly.
|
||||
return loadMessages(instanceId, sessionId, { force: true, intent: "latest",
|
||||
signal: options?.signal, registerInvalidation: options?.registerInvalidation })
|
||||
}
|
||||
log.error("Failed to load messages:", error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (isCurrent() && !message.includes("Stale read from")) {
|
||||
|
|
|
|||
|
|
@ -4,3 +4,14 @@
|
|||
scrollbar-gutter: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Follow the content edge so the rule meets any native scrollbar width. */
|
||||
.message-timeline .timeline-virtual-content[data-overflow="true"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--border-base) 50%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { sessions, setSessions, setActiveSession, setProviders } from "../../../
|
|||
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
|
||||
import { sseManager } from "../../../src/lib/sse-manager"
|
||||
import { loadMessages, loadLatestMessageWindow } from "../../../src/stores/session-api"
|
||||
import { historyWindowCursor } from "../../../src/stores/history-window"
|
||||
import { navigationMessage, navigationMessageId, mixedNavigationMessage } from "./history-navigation-data"
|
||||
import "../../../src/index.css"
|
||||
|
||||
|
|
@ -76,6 +77,13 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}
|
|||
latest: () => loadLatestMessageWindow(instanceId, sessionId),
|
||||
switchAway: () => { setActiveSession(instanceId, "other"); setVisible(false) },
|
||||
return: () => { setActiveSession(instanceId, sessionId); setVisible(true) },
|
||||
missingSavedAnchor: (evict: boolean) => {
|
||||
const target = { kind: "around" as const, messageID: "msg_removed" }
|
||||
store.setScrollSnapshot(sessionId, "message-stream", { scrollTop: 100, atBottom: false,
|
||||
anchorKey: target.messageID, anchorOffset: 0, followModeType: "escaped",
|
||||
windowIsLatest: !evict, ...(evict ? { windowCursor: historyWindowCursor(target) } : {}) })
|
||||
if (evict) store.clearSession(sessionId, { preserveScroll: 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
|
||||
|
|
|
|||
|
|
@ -99,6 +99,47 @@ async function clickTimeline(page: Page, index: number, checkReaderPosition = fa
|
|||
}
|
||||
const snapshot = (page: Page) => page.evaluate(() => (window as any).fixture.snapshot())
|
||||
|
||||
for (const evict of [false, true]) test(`missing saved anchor recovers once on session return (${evict ? 'evicted' : 'resident'})`, async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
await f.page.evaluate(evict => {
|
||||
;(window as any).fixture.switchAway()
|
||||
;(window as any).fixture.missingSavedAnchor(evict)
|
||||
;(window as any).fixture.return()
|
||||
}, evict)
|
||||
await f.page.waitForFunction(() => {
|
||||
const s = (window as any).fixture.snapshot()
|
||||
return s.scroll?.atBottom && s.scroll?.anchorKey !== 'msg_removed' && s.ids.includes((window as any).fixture.id(1499))
|
||||
})
|
||||
assert.equal(f.windows.filter(w => w.messageID === 'msg_removed').length, 1)
|
||||
await f.page.evaluate(() => (window as any).fixture.switchAway())
|
||||
await f.page.evaluate(() => (window as any).fixture.return())
|
||||
await f.page.waitForTimeout(400)
|
||||
assert.equal(f.windows.filter(w => w.messageID === 'msg_removed').length, 1, 'dead anchor must be retired, including saved window cursor')
|
||||
assert.equal(await f.page.getByRole('button', { name: /^(Reload messages|Recharger les messages)$/ }).count(), 0)
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("restoration ownership conflicts retain the saved passage instead of falling back", async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
let conflicts = 0
|
||||
await f.page.route('**/session-history/window', route => { conflicts++; return route.fulfill({ json: { status: 'blocked', reason: 'conflict' } }) })
|
||||
await f.page.evaluate(() => {
|
||||
;(window as any).fixture.switchAway()
|
||||
;(window as any).fixture.missingSavedAnchor(false)
|
||||
;(window as any).fixture.return()
|
||||
})
|
||||
await f.page.getByRole('button', { name: /^(Reload messages|Recharger les messages)$/ }).first().waitFor()
|
||||
assert.equal(conflicts, 1, JSON.stringify({ snapshot: await snapshot(f.page), windows: f.windows, errors: f.errors }))
|
||||
const failed = await snapshot(f.page)
|
||||
assert.equal(failed.scroll.anchorKey, 'msg_removed', 'conflict must preserve the reading destination')
|
||||
assert.equal(failed.scroll.atBottom, false, 'only a confirmed missing anchor may reset to latest')
|
||||
await f.page.waitForTimeout(300)
|
||||
assert.equal((await snapshot(f.page)).nativeLists, failed.nativeLists, 'conflict must not start a retry loop')
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
test("returning during an incomplete outline resumes accepted pages and retains the completed rail", async () => {
|
||||
const f = await fixture(false, true)
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ export async function testSessionNavigationNative({ client, location, locationOp
|
|||
const rpc = async (method, input) => (await client.rpc.call({ rpcID: "codenomad.session-pruning", method,
|
||||
input: { sessionID: session.id, ...input }, location }, locationOptions)).output
|
||||
try {
|
||||
assert.deepEqual(await rpc("window", { target: { kind: "around", messageID: "msg_removed" } }),
|
||||
{ status: "blocked", reason: "anchor_missing" }, "missing restore anchor is distinct from ownership conflicts")
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue