diff --git a/packages/app/e2e/regression/session-removed-worktree.spec.ts b/packages/app/e2e/regression/session-removed-worktree.spec.ts new file mode 100644 index 00000000000..447d780fe4e --- /dev/null +++ b/packages/app/e2e/regression/session-removed-worktree.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test" +import { base64Encode } from "@opencode/util/encode" +import { fixture } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" + +test("keeps a session in a removed worktree readable and movable", async ({ page }) => { + const missing = "/projects/removed-worktree" + const destination = fixture.directory + const sessionID = "ses_removed_worktree" + const session = { id: sessionID, projectID: fixture.project.id, directory: missing, title: "Removed worktree" } + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: destination, + project: fixture.project, + provider: fixture.provider, + sessions: [session], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Saved conversation in removed worktree", time: { created: 1 } }], + }), + }) + await page.route("**/api/**", (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get("location[directory]") !== missing) return route.fallback() + if (!["/api/location", "/api/agent", "/api/provider", "/api/model", "/api/model/default"].includes(url.pathname)) + return route.fallback() + return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } }) + }) + await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" })) + + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`, { waitUntil: "domcontentloaded" }) + await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible() + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6") + await prompt.fill("Keep the draft while moving") + await expect(prompt).toHaveText("Keep the draft while moving") + await transport.waitForConnection() + await page.getByRole("button", { name: "Session details" }).click() + await page.getByRole("button", { name: "Local repository" }).click() + const create = page.waitForRequest( + (request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "POST", + { timeout: 10_000 }, + ) + const move = page.waitForRequest( + (request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST", + ) + await page.getByRole("menuitem", { name: "New worktree" }).click() + expect((await create).postDataJSON()).toMatchObject({ projectID: fixture.project.id, from: fixture.project.worktree }) + const created = `${destination}/copy` + expect((await move).postDataJSON()).toMatchObject({ directory: created }) + session.directory = created + await transport.send({ + id: "evt_removed_worktree_moved", + type: "session.moved", + created: 2, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { sessionID, location: { directory: created }, projectID: fixture.project.id }, + }) + await expect(prompt).toBeEditable() + await expect(prompt).toHaveText("Keep the draft while moving") + await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible() +}) diff --git a/packages/app/src/composer/selection.ts b/packages/app/src/composer/selection.ts index d342581d939..1d2b9e21944 100644 --- a/packages/app/src/composer/selection.ts +++ b/packages/app/src/composer/selection.ts @@ -17,13 +17,13 @@ export function createComposerControls(input: { sessionKey: Accessor; mo const local = useLocal() const sdk = useWorkspaceLocation() const data = useData() - const providers = useProviders(() => sdk().directory) + const providers = useProviders(() => sdk().catalogDirectory) const view = layout.view(input.sessionKey) return createMemo(() => { return { agents: { - available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []), + available: normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []), options: local.agent.list().map((agent) => agent.name), current: local.agent.current()?.name ?? "", visible: local.agent.visible(), @@ -34,7 +34,7 @@ export function createComposerControls(input: { sessionKey: Accessor; mo paid: providers.paid().length > 0, loading: !(input.model ?? local.model).ready() || - (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || + (local.agent.visible() && data.location.agent.list({ directory: sdk().catalogDirectory }) === undefined) || !providers.ready(), }, session: { @@ -67,7 +67,7 @@ export function createComposerModelSelection(input: { }, ), ) - const providers = useProviders(() => sdk().directory) + const providers = useProviders(() => sdk().catalogDirectory) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const valid = (model: Pick) => { diff --git a/packages/app/src/providers/models/configured.ts b/packages/app/src/providers/models/configured.ts index e16d41ac133..7ab0269de71 100644 --- a/packages/app/src/providers/models/configured.ts +++ b/packages/app/src/providers/models/configured.ts @@ -9,13 +9,13 @@ export function useConfiguredModel() { const server = useServerSDK() createEffect( on( - () => [location().directory, server.connection.status()] as const, + () => [location().catalogDirectory, server.connection.status()] as const, ([directory]) => { void data.location.config.sync({ directory }).catch(() => undefined) }, ), ) - const documents = () => data.location.config.list({ directory: location().directory }) + const documents = () => data.location.config.list({ directory: location().catalogDirectory }) const model = createMemo(() => { const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined) const model = entry?.type === "document" ? entry.info.model : undefined diff --git a/packages/app/src/providers/models/selection.tsx b/packages/app/src/providers/models/selection.tsx index 5ad40f52dd6..1a9c63ed31c 100644 --- a/packages/app/src/providers/models/selection.tsx +++ b/packages/app/src/providers/models/selection.tsx @@ -80,14 +80,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sdk = useWorkspaceLocation() const data = useData() const serverSDK = useServerSDK() - const providers = useProviders(() => sdk().directory) + const providers = useProviders(() => sdk().catalogDirectory) const models = useModels() const settings = useSettings() const configuredModel = useConfiguredModel() const id = createMemo(() => params.id || undefined) const list = createMemo(() => - normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter( + normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []).filter( (item) => item.mode !== "subagent" && !item.hidden, ), ) diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index 0900b868de3..7bda1ed529a 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -1,4 +1,6 @@ -import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { ErrorBoundary, createEffect, createMemo, onCleanup, Show, type ParentProps } from "solid-js" +import { createStore } from "solid-js/store" +import { retry } from "@opencode/util/retry" import { useParams } from "@solidjs/router" import { DataProvider } from "@opencode/session-ui/context" import { SessionUserMessage } from "@opencode/session-ui/message" @@ -17,6 +19,7 @@ import { ServerConnection } from "@/runtime/server/registry" import { TerminalProvider } from "@/session/terminal/context" import { useSettingsCommand } from "@/settings/command" import { SessionUIProvider } from "@/shell/routes/session-ui-provider" +import { isProjectDirectory } from "@/workspaces/paths" import { useTabs, type PendingSession } from "@/shell/tabs/tabs" import { requireServerKey } from "@/shell/routes/session" import { useSessionModel } from "./model" @@ -33,15 +36,54 @@ export function TargetSessionRouteContent() { const data = useData() const server = useServer() const tabs = useTabs() - const directory = createMemo(() => data.session.get(params.id)?.location.directory) + const [locationState, setLocationState] = createStore({ unavailableSource: "" }) + createEffect(() => { + const session = data.session.get(params.id) + if (!session) return + const source = session.location.directory + const project = data.project.get(session.projectID) + if ( + !project || + data.location.info({ directory: source }) || + isProjectDirectory({ worktree: project.canonical, sandboxes: project.sandboxes }, source) + ) + return + let stale = false + onCleanup(() => { + stale = true + }) + void retry(() => data.location.syncInfo({ directory: source }), { retryIf: () => !stale }).then( + () => { + if (!stale) setLocationState("unavailableSource", "") + }, + () => { + if (!stale) setLocationState("unavailableSource", source) + }, + ) + }) + const catalogDirectory = createMemo(() => { + const session = data.session.get(params.id) + if (!session) return + const source = session.location.directory + const project = data.project.get(session.projectID) + if (!project || source !== locationState.unavailableSource || data.location.info({ directory: source })) + return source + if (data.location.provider.list({ directory: source }) && data.location.model.list({ directory: source })) + return source + // A removed worktree cannot supply a catalog, but its session still belongs to the known project. + return project.canonical + }) return ( <> params.id} /> - + - }> + } + > {(pending) => } @@ -111,7 +153,7 @@ function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serv ) } -function ResolvedTargetSessionRoute() { +function ResolvedTargetSessionRoute(props: { catalogDirectory: () => string | undefined }) { const params = useParams<{ id: string }>() const server = useServer() const tabs = useTabs() @@ -136,8 +178,8 @@ function ResolvedTargetSessionRoute() { > }> {(value) => ( - - + + diff --git a/packages/app/src/session/timeline/session-workspace-menu.tsx b/packages/app/src/session/timeline/session-workspace-menu.tsx index 637c1d1cc7f..c347b763583 100644 --- a/packages/app/src/session/timeline/session-workspace-menu.tsx +++ b/packages/app/src/session/timeline/session-workspace-menu.tsx @@ -69,7 +69,7 @@ export function SessionWorkspaceMenu(props: { api: sdk.api, data, directory: props.directory, - project: data.location.info({ directory: props.directory })?.project, + project: { id: props.project.id, canonical: props.project.worktree, directory: props.project.worktree }, }) : selection if (!destination) return diff --git a/packages/app/src/shell/routes/session-ui-provider.tsx b/packages/app/src/shell/routes/session-ui-provider.tsx index bb45b941416..1e1864dc040 100644 --- a/packages/app/src/shell/routes/session-ui-provider.tsx +++ b/packages/app/src/shell/routes/session-ui-provider.tsx @@ -14,6 +14,7 @@ import { readLocalImage } from "@/runtime/server/image" export function SessionUIProvider( props: ParentProps<{ directory: string + providerDirectory?: string server: ServerConnection.Key }>, ) { @@ -39,7 +40,7 @@ export function SessionUIProvider( await data.session.sync(sessionID).catch(() => undefined) navigate(href(sessionID)) } - const providers = useProviders(directory) + const providers = useProviders(() => props.providerDirectory ?? directory()) const sessionUIData = createMemo(() => ({ provider: providers.ready() ? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) } diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 4c09b213031..57207a9f1f5 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -9,11 +9,16 @@ export type { LocationContext } from "@/runtime/server/client" export type WorkspaceLocation = LocationContext & { readonly ref: LocationRef readonly current: LocationGetOutput | undefined + readonly catalogDirectory: string } const context = createSimpleContext({ name: "Location", - init: (props: { directory: string | Accessor; workspaceID?: string | Accessor }) => { + init: (props: { + directory: string | Accessor + workspaceID?: string | Accessor + catalogDirectory?: Accessor + }) => { const serverSDK = useServerSDK() const server = useServer() const data = useData() @@ -53,6 +58,7 @@ const context = createSimpleContext({ ...location(), ref: ref(), current: current(), + catalogDirectory: props.catalogDirectory?.() ?? ref().directory, })) }, })