diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 1e3a81dc..2301ad98 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -202,6 +202,8 @@ Rules: 10. Session-scoped ArrowUp and ArrowDown recall merges the visible transcript's user prompts (`useUserMessageHistory`) with the persisted input-history bucket for runtime + normalized directory + session identity. Revert markers hide prompts from the transcript source only; the persisted bucket still recalls them. Global scope reads the persisted runtime bucket alone. 11. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted. +A successful local session creation publishes its session record and calls `SessionMessageLoader.initializeCreatedSession` before selection starts navigation loading. The create response establishes an empty transcript only if no transcript has arrived yet. Initialization supersedes an earlier unresolved history load, preserves any messages or metadata received before the create response, and uses the server-returned directory. Opening that new session needs no history read; forced recovery and later eviction still use normal fetching. Creation responses from a previous runtime cannot select or initialize a session in the current runtime. + Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering. ## Failed-turn diagnostics @@ -272,7 +274,7 @@ The discriminator is whether the server confirmed the path, not whether the valu Rules: -1. Ownership comes from the session record's own `directory`. `getSyncSessionDirectory()` reports *containment*, not ownership, and is only the fallback for a record without a directory: a project's session list includes the sessions of its worktrees so the sidebar can group them, so the parent repository holds worktree sessions too, and reading ownership from membership routes a worktree session to its parent. `null` means "not indexed yet", never "no directory". +1. Ownership comes from the session record's own `directory`. When directory sync has no owning record yet, the global session index supplies that record's directory before local selection, worktree, or remembered hints. `getSyncSessionDirectory()` reports *containment*, not ownership, and is only the fallback for a record without a directory: a project's session list includes the sessions of its worktrees so the sidebar can group them, so the parent repository holds worktree sessions too, and reading ownership from membership routes a worktree session to its parent. `null` means "not indexed yet", never "no directory". 2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent. 3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts. 4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically. diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 250daf09..4fb1f807 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -891,6 +891,7 @@ export async function createSession( metadata?: Record, selectionTransition?: "submitted-draft", ): Promise { + const runtimeKey = getRuntimeKey() try { // Capture the effective directory used for session creation so we can fall // back to it when the server response omits the `directory` field. @@ -904,11 +905,22 @@ export async function createSession( metadata, }, effectiveDirectory) + if (getRuntimeKey() !== runtimeKey) return null const sessionDirectory = (session as { directory?: string | null }).directory ?? effectiveDirectory ?? null // Pre-populate routing index so SSE events arriving before session.created // can be routed to the correct child store if (sessionDirectory) { registerSessionDirectory(session.id, sessionDirectory) + const store = _childStores?.ensureChild(sessionDirectory, { bootstrap: false }) + if (store) { + const current = store.getState().session + const existing = Binary.search(current, session.id, (candidate) => candidate.id) + // An event may have published newer metadata before the create response. + if (!existing.found) { + store.setState({ session: [...current.slice(0, existing.index), session, ...current.slice(existing.index)] }) + } + } + getImperativeSessionMessageLoader()?.initializeCreatedSession({ directory: sessionDirectory, sessionID: session.id }) } useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition) useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id) diff --git a/packages/ui/src/sync/session-creation-loading.test.ts b/packages/ui/src/sync/session-creation-loading.test.ts new file mode 100644 index 00000000..845281f5 --- /dev/null +++ b/packages/ui/src/sync/session-creation-loading.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2/client" +import { opencodeClient } from "@/lib/opencode/client" +import { getRuntimeKey } from "@/lib/runtime-switch" +import { ChildStoreManager } from "./child-store" +import { createSession, setActionRefs } from "./session-actions" +import { SessionMessageLoader, setImperativeSessionMessageLoader } from "./session-message-loader" +import { useSessionUIStore } from "./session-ui-store" + +const originalCreateSession = opencodeClient.createSession +const originalDirectory = opencodeClient.getDirectory() +const originalSelection = useSessionUIStore.getState() +let childStores: ChildStoreManager +let loader: SessionMessageLoader +let requests = 0 +const sdk = createOpencodeClient({ + baseUrl: "http://session-creation.test", + fetch: async () => { + requests += 1 + return Response.json({ message: "not found" }, { status: 404 }) + }, +}) +const session: Session = { + id: "session-created", + slug: "created", + projectID: "project-created", + directory: "C:/canonical/worktree", + title: "New session", + version: "1", + time: { created: 1, updated: 1 }, +} + +beforeEach(() => { + requests = 0 + childStores = new ChildStoreManager() + loader = new SessionMessageLoader(childStores, { sdk, runtimeKey: getRuntimeKey() }) + setActionRefs(sdk, childStores, () => "/requested") + setImperativeSessionMessageLoader(loader) +}) + +afterEach(() => { + opencodeClient.createSession = originalCreateSession + opencodeClient.setDirectory(originalDirectory) + useSessionUIStore.setState(originalSelection) + setImperativeSessionMessageLoader(null) + loader.dispose() + childStores.disposeAll() +}) + +describe("confirmed session creation", () => { + test("publishes the new transcript before navigation can issue a failing history read", async () => { + opencodeClient.createSession = async () => session + + expect(await createSession(undefined, "/requested")).toBe(session) + const target = { directory: session.directory, sessionID: session.id } + await loader.ensure(target, { reason: "reactive" }) + + expect(requests).toBe(0) + expect(useSessionUIStore.getState().currentSessionDirectory).toBe(session.directory) + expect(childStores.getChild(session.directory)?.getState().session).toEqual([session]) + expect(childStores.getChild(session.directory)?.getState().message[session.id]).toEqual([]) + expect(loader.getSnapshot(target).status).toBe("ready") + expect(childStores.getChild("/requested")?.getState().message[session.id]).toBeUndefined() + }) + + test("retains newer metadata and the first prompt delivered before the create response", async () => { + const store = childStores.ensureChild(session.directory, { bootstrap: false }) + const newerSession = { ...session, title: "Already renamed", time: { created: 1, updated: 2 } } + const record = { + id: "msg_first", + sessionID: session.id, + role: "user", + time: { created: 2 }, + agent: "build", + model: { providerID: "test", modelID: "test" }, + } satisfies import("@opencode-ai/sdk/v2/client").UserMessage + opencodeClient.createSession = async () => { + store.setState({ session: [newerSession], message: { [session.id]: [record] } }) + return session + } + + await createSession(undefined, "/requested") + + expect(requests).toBe(0) + expect(store.getState().session).toEqual([newerSession]) + expect(store.getState().message[session.id]).toEqual([record]) + }) + + test("a rejected create does not seed an empty successful transcript", async () => { + opencodeClient.createSession = async () => { throw new Error("offline") } + const previousSelection = useSessionUIStore.getState().currentSessionId + + expect(await createSession(undefined, "/requested")).toBeNull() + + expect(useSessionUIStore.getState().currentSessionId).toBe(previousSelection) + expect(childStores.getChild(session.directory)).toBeUndefined() + expect(requests).toBe(0) + }) +}) diff --git a/packages/ui/src/sync/session-directory-adoption.test.ts b/packages/ui/src/sync/session-directory-adoption.test.ts index 4857872c..84620bb2 100644 --- a/packages/ui/src/sync/session-directory-adoption.test.ts +++ b/packages/ui/src/sync/session-directory-adoption.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test } from "bun:test" import { ChildStoreManager } from "./child-store" import { setSyncRefs } from "./sync-refs" import { useSessionUIStore } from "./session-ui-store" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" /** * Selecting a session whose directory this client has not indexed yet routes it @@ -39,6 +40,27 @@ beforeEach(() => { }) describe("adoptAuthoritativeSessionDirectory", () => { + test("opens a globally indexed Windows worktree session before its child store bootstraps", () => { + const sessionId = "ses_windows_global_directory" + useGlobalSessionsStore.getState().upsertSession({ + id: sessionId, + slug: "windows-worktree", + projectID: "windows-project", + directory: "c:\\repo\\.worktrees\\feature", + title: "Worktree session", + version: "1", + time: { created: 1, updated: 1 }, + }) + try { + useSessionUIStore.getState().setCurrentSession(sessionId) + + expect(useSessionUIStore.getState().currentSessionDirectory).toBe("C:/repo/.worktrees/feature") + expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("C:/repo/.worktrees/feature") + } finally { + useGlobalSessionsStore.getState().removeSessions([sessionId]) + } + }) + test("promotes a guessed selection once the owning directory is indexed", () => { useSessionUIStore.getState().setCurrentSession(SESSION_ID) expect(useSessionUIStore.getState().currentSessionDirectory).not.toBe(WORKTREE) diff --git a/packages/ui/src/sync/session-message-loader.test.ts b/packages/ui/src/sync/session-message-loader.test.ts index 83ab5fc7..a87122a3 100644 --- a/packages/ui/src/sync/session-message-loader.test.ts +++ b/packages/ui/src/sync/session-message-loader.test.ts @@ -38,6 +38,72 @@ const createLoader = (messages: (input: { } describe("SessionMessageLoader", () => { + test("opens a confirmed new session without fetching history", async () => { + let calls = 0 + const { childStores, loader } = createLoader(async () => { + calls += 1 + return { error: { message: "not found" }, response: { status: 404 } } + }) + const target = { directory: "/created-repo", sessionID: "session-created" } + + loader.initializeCreatedSession(target) + await loader.ensure(target, { reason: "navigation" }) + await loader.ensure(target, { reason: "reactive" }) + + expect(calls).toBe(0) + expect(loader.getSnapshot(target)).toMatchObject({ status: "ready", resolved: true, complete: true }) + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([]) + + const record = createRecord(target.sessionID) + loader.optimisticAdd({ ...target, message: record.info, parts: record.parts }) + await loader.ensure(target) + expect(calls).toBe(0) + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info]) + + // Explicit recovery still reaches the server and exposes a real failure. + await loader.ensure(target, { force: true }) + expect(calls).toBe(1) + expect(loader.getSnapshot(target).status).toBe("error") + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info]) + loader.dispose() + childStores.disposeAll() + }) + + test("creation supersedes an early history failure without losing the first prompt", async () => { + const pending = deferred<{ error: { message: string }; response: { status: number } }>() + const { childStores, loader } = createLoader(() => pending.promise) + const target = { directory: "/created-race", sessionID: "session-created" } + const earlyLoad = loader.ensure(target) + + loader.initializeCreatedSession(target) + const record = createRecord(target.sessionID) + loader.optimisticAdd({ ...target, message: record.info, parts: record.parts }) + pending.resolve({ error: { message: "not found" }, response: { status: 404 } }) + await earlyLoad + + expect(loader.getSnapshot(target).status).toBe("ready") + expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toEqual([record.info]) + expect(childStores.getChild(target.directory)?.getState().part[record.info.id]).toEqual(record.parts) + loader.dispose() + childStores.disposeAll() + }) + + test("creation preserves messages and history coverage received before its response", async () => { + const record = createRecord("session-created") + const { childStores, loader } = createLoader(async () => response([record], "older-cursor")) + const target = { directory: "/created-events", sessionID: "session-created" } + await loader.ensure(target) + const before = childStores.getChild(target.directory)?.getState() + const coverage = loader.getSnapshot(target) + + loader.initializeCreatedSession(target) + + expect(childStores.getChild(target.directory)?.getState()).toBe(before) + expect(loader.getSnapshot(target)).toBe(coverage) + loader.dispose() + childStores.disposeAll() + }) + test("deduplicates navigation and reactive loading for the same target", async () => { const pending = deferred>() let calls = 0 diff --git a/packages/ui/src/sync/session-message-loader.ts b/packages/ui/src/sync/session-message-loader.ts index af1b2c9f..a61bf4b4 100644 --- a/packages/ui/src/sync/session-message-loader.ts +++ b/packages/ui/src/sync/session-message-loader.ts @@ -176,6 +176,30 @@ export class SessionMessageLoader { this.disposed = false } + initializeCreatedSession(target: SessionMessageTarget): void { + const normalized = this.normalizeTarget(target) + if (!normalized || this.disposed) return + const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false }) + const current = store.getState() + // The create response establishes an empty transcript, but events or a + // prompt may already have materialized a newer snapshot while it travelled. + if (current.message[normalized.sessionID] !== undefined) return + const entry = this.getEntry(normalized) + this.bumpGeneration(entry) + entry.inflight = null + store.setState({ message: { ...current.message, [normalized.sessionID]: [] } }) + this.patchEntry(entry, { + status: "ready", + loadingKind: null, + error: null, + resolved: true, + cursor: undefined, + complete: true, + updatedAt: Date.now(), + }) + this.persistCoverage(normalized, entry.snapshot) + } + ensure( target: SessionMessageTarget, options?: { force?: boolean; reason?: "navigation" | "reactive" | "prefetch" }, diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 4dd49c7b..c8532cc0 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -526,6 +526,11 @@ const getAuthoritativeSessionDirectory = (sessionId: string): string | null => { const target = getAllSyncSessions().find((s) => s.id === sessionId) const recordDirectory = target ? resolveDirectoryKey(target) : null if (recordDirectory) return normalizePath(recordDirectory) + // The sidebar can know this session before its directory store bootstraps. + // Use that record's own directory before falling back to local routing hints. + const globalSession = useGlobalSessionsStore.getState().entityById.get(sessionId) + const globalDirectory = normalizePath(globalSession?.directory) + if (globalDirectory) return globalDirectory const owningDirectory = getSyncSessionDirectory(sessionId) return owningDirectory ? normalizePath(owningDirectory) : null }