From 458386f1156190bd21d8733ce400edf0d42d0968 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Sun, 7 Jun 2026 08:02:01 +0200 Subject: [PATCH 1/3] fix(app): preserve prompt drafts across session switches --- packages/app/src/context/prompt.test.ts | 49 +++++++++++++++++++++++++ packages/app/src/context/prompt.tsx | 18 +++++---- 2 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/context/prompt.test.ts diff --git a/packages/app/src/context/prompt.test.ts b/packages/app/src/context/prompt.test.ts new file mode 100644 index 000000000000..cd7eebc2b704 --- /dev/null +++ b/packages/app/src/context/prompt.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" +import { ServerScope } from "@/utils/server-scope" + +let getPromptSessionCacheKey: typeof import("./prompt").getPromptSessionCacheKey +let isPromptSessionReady: typeof import("./prompt").isPromptSessionReady + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useParams: () => ({}), + useSearchParams: () => [{}], + })) + mock.module("@opencode-ai/ui/context", () => ({ + createSimpleContext: () => ({ + use: () => undefined, + provider: () => undefined, + }), + })) + const mod = await import("./prompt") + getPromptSessionCacheKey = mod.getPromptSessionCacheKey + isPromptSessionReady = mod.isPromptSessionReady +}) + +describe("getPromptSessionCacheKey", () => { + test("separates prompt sessions by server scope", () => { + const local = getPromptSessionCacheKey(ServerScope.local, { dir: "/repo", id: "ses_123" }) + const remote = getPromptSessionCacheKey("ssh:debian" as ServerScope, { dir: "/repo", id: "ses_123" }) + + expect(String(local)).toBe("local\u0000/repo\u0000ses_123") + expect(String(remote)).toBe("ssh:debian\u0000/repo\u0000ses_123") + expect(remote).not.toBe(local) + }) + + test("separates workspace prompt sessions by server scope", () => { + expect(String(getPromptSessionCacheKey(ServerScope.local, { dir: "/repo", id: undefined }))).toBe( + "local\u0000/repo\u0000__workspace__", + ) + }) + + test("keeps explicit draft sessions keyed by draft id", () => { + expect(getPromptSessionCacheKey(ServerScope.local, { draftID: "draft_123" })).toBe("draft:draft_123") + }) +}) + +describe("isPromptSessionReady", () => { + test("returns the readiness accessor value instead of the accessor function", () => { + expect(isPromptSessionReady({ ready: () => false })).toBe(false) + expect(isPromptSessionReady({ ready: () => true })).toBe(true) + }) +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 682439e92546..124ba8b4780e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -6,7 +6,7 @@ import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" -import type { ServerScope } from "@/utils/server-scope" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" import { useServer } from "./server" @@ -169,16 +169,18 @@ type PromptStore = { type Scope = { draftID: string } | { dir: string; id?: string } -function scopeKey(scope: Scope) { - if ("draftID" in scope) return `draft:${scope.draftID}` - return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` -} - type PromptCacheEntry = { value: PromptSession dispose: VoidFunction } +export const getPromptSessionCacheKey = (serverScope: ServerScope, scope: Scope) => { + if ("draftID" in scope) return `draft:${scope.draftID}` + return ScopedKey.from(serverScope, scope.dir, scope.id ?? WORKSPACE_KEY) +} + +export const isPromptSessionReady = (session: { ready: () => boolean }) => session.ready() + function promptTarget(serverScope: ServerScope, scope: Scope) { if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` @@ -195,7 +197,7 @@ export function createPromptSession(serverScope: ServerScope, scope: Scope) { } export function createPromptReady(session: Accessor) { - return Object.defineProperty(() => session().ready(), "promise", { + return Object.defineProperty(() => isPromptSessionReady(session()), "promise", { get: () => session().ready.promise, }) as (() => boolean) & { readonly promise: Promise | undefined } } @@ -320,7 +322,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return createTabPromptState(tabs, current, serverSDK().scope, scope) } - const key = scopeKey(scope) + const key = getPromptSessionCacheKey(serverSDK().scope, scope) const existing = cache.get(key) if (existing) { cache.delete(key) From da686746ea12606acdc88d405450369c54e6ea09 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 15 Jun 2026 13:14:35 +0200 Subject: [PATCH 2/3] fix(app): keep prompt ready promise lazy --- packages/app/src/context/prompt.test.ts | 19 +++++++++++++++++++ packages/app/src/context/prompt.tsx | 13 +++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/app/src/context/prompt.test.ts b/packages/app/src/context/prompt.test.ts index cd7eebc2b704..497dbbb2675b 100644 --- a/packages/app/src/context/prompt.test.ts +++ b/packages/app/src/context/prompt.test.ts @@ -3,6 +3,7 @@ import { ServerScope } from "@/utils/server-scope" let getPromptSessionCacheKey: typeof import("./prompt").getPromptSessionCacheKey let isPromptSessionReady: typeof import("./prompt").isPromptSessionReady +let createPromptReady: typeof import("./prompt").createPromptReady beforeAll(async () => { mock.module("@solidjs/router", () => ({ @@ -18,6 +19,7 @@ beforeAll(async () => { const mod = await import("./prompt") getPromptSessionCacheKey = mod.getPromptSessionCacheKey isPromptSessionReady = mod.isPromptSessionReady + createPromptReady = mod.createPromptReady }) describe("getPromptSessionCacheKey", () => { @@ -47,3 +49,20 @@ describe("isPromptSessionReady", () => { expect(isPromptSessionReady({ ready: () => true })).toBe(true) }) }) + +describe("createPromptReady", () => { + test("exposes a lazy promise property without reading the session during construction", () => { + let calls = 0 + const promise = Promise.resolve(true) + const ready = createPromptReady(() => { + calls++ + return { ready: Object.assign(() => true, { promise }) } + }) + + expect(calls).toBe(0) + expect(ready()).toBe(true) + expect(calls).toBe(1) + expect(ready.promise).toBe(promise) + expect(calls).toBe(2) + }) +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 124ba8b4780e..b3a8bd7616b6 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -174,6 +174,8 @@ type PromptCacheEntry = { dispose: VoidFunction } +type PromptReady = (() => boolean) & { promise: Promise | undefined } + export const getPromptSessionCacheKey = (serverScope: ServerScope, scope: Scope) => { if ("draftID" in scope) return `draft:${scope.draftID}` return ScopedKey.from(serverScope, scope.dir, scope.id ?? WORKSPACE_KEY) @@ -181,6 +183,17 @@ export const getPromptSessionCacheKey = (serverScope: ServerScope, scope: Scope) export const isPromptSessionReady = (session: { ready: () => boolean }) => session.ready() +export function createPromptReady(input: () => { ready: PromptReady }): PromptReady { + const ready = (() => isPromptSessionReady(input())) as PromptReady + Object.defineProperty(ready, "promise", { + enumerable: true, + get() { + return input().ready.promise + }, + }) + return ready +} + function promptTarget(serverScope: ServerScope, scope: Scope) { if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` From b281702e24409c84043ad88ee5d02a52ad1cbdb8 Mon Sep 17 00:00:00 2001 From: ualtinok Date: Mon, 15 Jun 2026 13:45:59 +0200 Subject: [PATCH 3/3] fix(app): avoid prompt scope before route params --- packages/app/src/context/prompt.test.ts | 23 ++++++++++++++++++ packages/app/src/context/prompt.tsx | 32 +++++++++++++++---------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/app/src/context/prompt.test.ts b/packages/app/src/context/prompt.test.ts index 497dbbb2675b..7b69a09b4c80 100644 --- a/packages/app/src/context/prompt.test.ts +++ b/packages/app/src/context/prompt.test.ts @@ -4,9 +4,12 @@ import { ServerScope } from "@/utils/server-scope" let getPromptSessionCacheKey: typeof import("./prompt").getPromptSessionCacheKey let isPromptSessionReady: typeof import("./prompt").isPromptSessionReady let createPromptReady: typeof import("./prompt").createPromptReady +let createPromptRouteScope: typeof import("./prompt").createPromptRouteScope beforeAll(async () => { mock.module("@solidjs/router", () => ({ + useLocation: () => ({}), + useNavigate: () => () => undefined, useParams: () => ({}), useSearchParams: () => [{}], })) @@ -20,6 +23,7 @@ beforeAll(async () => { getPromptSessionCacheKey = mod.getPromptSessionCacheKey isPromptSessionReady = mod.isPromptSessionReady createPromptReady = mod.createPromptReady + createPromptRouteScope = mod.createPromptRouteScope }) describe("getPromptSessionCacheKey", () => { @@ -66,3 +70,22 @@ describe("createPromptReady", () => { expect(calls).toBe(2) }) }) + +describe("createPromptRouteScope", () => { + test("returns undefined until a draft or directory scope is available", () => { + expect(createPromptRouteScope({})).toBeUndefined() + }) + + test("prefers draft scope over route directory scope", () => { + expect(createPromptRouteScope({ draftId: "draft-1", dir: "/tmp/project", id: "ses_1" })).toEqual({ + draftID: "draft-1", + }) + }) + + test("uses route directory scope when available", () => { + expect(createPromptRouteScope({ dir: "/tmp/project", id: "ses_1" })).toEqual({ + dir: "/tmp/project", + id: "ses_1", + }) + }) +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index b3a8bd7616b6..359ad9e42ae5 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,7 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" -import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js" +import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" @@ -167,7 +167,7 @@ type PromptStore = { } } -type Scope = { draftID: string } | { dir: string; id?: string } +export type Scope = { draftID: string } | { dir: string; id?: string } type PromptCacheEntry = { value: PromptSession @@ -183,6 +183,12 @@ export const getPromptSessionCacheKey = (serverScope: ServerScope, scope: Scope) export const isPromptSessionReady = (session: { ready: () => boolean }) => session.ready() +export function createPromptRouteScope(input: { draftId?: string; dir?: string; id?: string }) { + if (input.draftId) return { draftID: input.draftId } + if (input.dir) return { dir: input.dir, id: input.id } + return undefined +} + export function createPromptReady(input: () => { ready: PromptReady }): PromptReady { const ready = (() => isPromptSessionReady(input())) as PromptReady Object.defineProperty(ready, "promise", { @@ -209,12 +215,6 @@ export function createPromptSession(serverScope: ServerScope, scope: Scope) { return { ready, ...createPromptStateValue(store, setStore) } } -export function createPromptReady(session: Accessor) { - return Object.defineProperty(() => isPromptSessionReady(session()), "promise", { - get: () => session().ready.promise, - }) as (() => boolean) & { readonly promise: Promise | undefined } -} - function promptStore(): PromptStore { return { prompt: clonePrompt(DEFAULT_PROMPT), @@ -270,7 +270,7 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction

(promptStore()) - const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) as PromptReady return { ready, ...createPromptStateValue(store, setStore), @@ -356,9 +356,17 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return entry.value } - const session = createMemo(() => - load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }), - ) + const fallback = createPromptState() + const session = createMemo(() => { + const directory = sdk().directory + const scope = createPromptRouteScope({ + draftId: search.draftId, + dir: directory ? base64Encode(directory) : undefined, + id: params.id, + }) + if (scope) return load(scope) + return fallback + }) const pick = (scope?: Scope) => (scope ? load(scope) : session()) const ready = createPromptReady(session)