diff --git a/packages/app/e2e/regression/session-location-missing.spec.ts b/packages/app/e2e/regression/session-location-missing.spec.ts deleted file mode 100644 index 3167bd3bade..00000000000 --- a/packages/app/e2e/regression/session-location-missing.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { expect, test } from "@playwright/test" -import { base64Encode } from "@opencode-ai/util/encode" -import type { OpenCodeEvent } from "@opencode-ai/client/promise" -import { fixture } from "../smoke/session-timeline.fixture" -import { mockOpenCodeServer } from "../utils/mock-server" - -test("keeps history visible and recovers the composer by choosing another directory", async ({ page }) => { - const directory = "/projects/deleted" - const destination = "/projects/restored" - const sessionID = "ses_missing_location" - const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" } - const events: OpenCodeEvent[] = [] - const moves: unknown[] = [] - await mockOpenCodeServer(page, { - directory: destination, - project: { ...fixture.project, worktree: destination }, - provider: fixture.provider, - sessions: [session], - events: () => events.splice(0), - fileList: () => [], - pageMessages: () => ({ - items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }], - }), - }) - await page.route("**/api/**", (route) => { - if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback() - return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } }) - }) - await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) - await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible() - await expect(page.getByRole("status")).toContainText("Session location unavailable") - await expect(page.getByRole("status")).toContainText(directory) - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0) - const choose = page.getByRole("button", { name: "Choose directory", exact: true }) - await expect(choose).toBeEnabled() - await choose.click() - const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true }) - await expect(dialog.getByRole("combobox")).toBeFocused() - await dialog.getByRole("combobox").press("Escape") - await expect(dialog).toHaveCount(0) - await expect(choose).toBeFocused() - expect(moves).toEqual([]) - - // The server can still reject a destination after it was selected in the picker. - await page.route(`**/api/session/${sessionID}/move`, (route) => { - if (route.request().method() !== "POST") return route.fallback() - moves.push(route.request().postDataJSON()) - return route.fulfill({ - status: 400, - json: { _tag: "InvalidRequestError", message: "Destination is unavailable" }, - headers: { "access-control-allow-origin": "*" }, - }) - }) - await choose.press("Enter") - await expect(dialog.getByRole("combobox")).toBeFocused() - await dialog.getByRole("combobox").fill(destination) - await dialog.getByRole("combobox").press("Enter") - await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination) - await dialog.getByRole("button", { name: "Select folder", exact: true }).click() - await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible() - await expect(choose).toBeEnabled() - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0) - await expect(choose).toBeFocused() - expect(moves).toEqual([{ directory: destination }]) - - await page.route(`**/api/session/${sessionID}/move`, (route) => { - if (route.request().method() !== "POST") return route.fallback() - moves.push(route.request().postDataJSON()) - session.directory = destination - events.push({ - id: "evt_location_recovered", - type: "session.moved", - created: 2, - durable: { aggregateID: sessionID, seq: 1, version: 1 }, - data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, - }) - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - }) - await choose.click() - await dialog.getByRole("combobox").fill(destination) - await dialog.getByRole("combobox").press("Enter") - await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination) - await dialog.getByRole("button", { name: "Select folder", exact: true }).click() - await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() - await expect(page.locator('[data-action="composer-model"]')).toBeVisible() - await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible() - await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`)) - expect(moves).toEqual([{ directory: destination }, { directory: destination }]) -}) - -for (const create of [false, true]) { - test(`recovers into ${create ? "a new" : "an existing"} worktree without resolving the deleted location`, async ({ - page, - }) => { - const canonical = "/projects/repository" - const directory = "/projects/deleted-worktree" - const destination = create ? "/projects/new-worktree" : "/projects/existing-worktree" - const sessionID = "ses_worktree_recovery" - const events: OpenCodeEvent[] = [] - const requests: { operation: string; body: unknown }[] = [] - let listing = Promise.withResolvers() - let listingRequested = Promise.withResolvers() - const moving = Promise.withResolvers() - const moveRequested = Promise.withResolvers() - await mockOpenCodeServer(page, { - directory: canonical, - project: { ...fixture.project, worktree: canonical }, - provider: fixture.provider, - sessions: [{ id: sessionID, projectID: fixture.project.id, directory }], - pageMessages: () => ({ - items: [{ id: "msg_worktree", type: "user", text: "Recover my worktree", time: { created: 1 } }], - }), - events: () => events.splice(0), - }) - await page.route("**/api/**", async (route) => { - const url = new URL(route.request().url()) - const headers = { "access-control-allow-origin": "*" } - if (url.searchParams.get("location[directory]") === directory) - return route.fulfill({ status: 500, body: "", headers }) - if (url.pathname === `/api/worktree/${fixture.project.id}`) { - if (route.request().method() === "GET") { - listingRequested.resolve() - await listing.promise - return route.fulfill({ - json: [ - { directory: canonical, strategy: null }, - { directory: "/projects/existing-worktree", strategy: "git" }, - ...Array.from({ length: 20 }, (_, index) => ({ - directory: `/projects/worktree-${index}`, - strategy: "git", - })), - ], - headers, - }) - } - if (route.request().method() === "POST") { - requests.push({ operation: "create", body: route.request().postDataJSON() }) - return route.fulfill({ - json: { directory: destination, name: "new-worktree", branch: "new-worktree" }, - headers, - }) - } - } - if (url.pathname === `/api/session/${sessionID}/move` && route.request().method() === "POST") { - requests.push({ operation: "move", body: route.request().postDataJSON() }) - moveRequested.resolve() - await moving.promise - events.push({ - id: "evt_worktree_recovered", - type: "session.moved", - created: 2, - durable: { aggregateID: sessionID, seq: 1, version: 1 }, - data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, - }) - return route.fulfill({ status: 204, headers }) - } - return route.fallback() - }) - await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) - await page.getByRole("button", { name: "Choose worktree", exact: true }).click() - await listingRequested.promise - await expect(page.getByRole("menuitem", { name: "Loading", exact: true })).toBeVisible() - await expect(page.getByRole("menuitem", { name: "New workspace", exact: true })).toBeVisible() - await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible() - await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible() - listing.resolve() - const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true }) - await expect(existing).toBeVisible() - await expect(existing).toHaveCSS("height", "28px") - await page.keyboard.press("Escape") - await expect(page.getByRole("menu")).toHaveCount(0) - listing = Promise.withResolvers() - listingRequested = Promise.withResolvers() - await page.getByRole("button", { name: "Choose worktree", exact: true }).click() - await listingRequested.promise - await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible() - await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible() - listing.resolve() - await page.getByRole("menuitem", { name: create ? "New workspace" : "existing-worktree", exact: true }).click() - await moveRequested.promise - await expect(page.getByRole("button", { name: "Moving session…", exact: true })).toBeDisabled() - await expect(page.getByRole("button", { name: "Choose worktree", exact: true })).toBeDisabled() - moving.resolve() - await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) - await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() - await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible() - expect(requests).toEqual([ - ...(create ? [{ operation: "create", body: { strategy: "git", from: canonical, directory: "/projects/" } }] : []), - { operation: "move", body: { directory: destination } }, - ]) - }) -} diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts new file mode 100644 index 00000000000..f281b263475 --- /dev/null +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -0,0 +1,227 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/util/encode" +import { fixture } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { installSseTransport } from "../utils/sse-transport" + +for (const endpoint of ["/api/location", "/api/agent"]) { + for (const recover of [false, true]) { + test(`keeps the composer when ${endpoint} ${recover ? "recovers on retry" : "fails"}`, async ({ page }) => { + const recovery = recoveryRequests(page) + const directory = "/projects/working-tree" + const sessionID = "ses_location_sync_failure" + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [{ id: sessionID, projectID: fixture.project.id, directory, title: "Workspace sync" }], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Keep working in this worktree", time: { created: 1 } }], + }), + }) + let requests = 0 + await page.route("**/api/**", (route) => { + const url = new URL(route.request().url()) + if (url.pathname !== endpoint || url.searchParams.get("location[directory]") !== directory) + return route.fallback() + requests++ + if (recover && requests > 1) return route.fallback() + return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } }) + }) + const failure = page.waitForResponse( + (response) => new URL(response.url()).pathname === endpoint && response.status() === 500, + ) + const settled = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === endpoint && + url.searchParams.get("location[directory]") === directory && + (recover ? response.ok() : requests === 3 && response.status() === 500) + ) + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await failure + await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible() + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Continue after reconnecting") + await expect(prompt).toHaveText("Continue after reconnecting") + await settled + await expect(prompt).toBeEditable() + await expect(prompt).toHaveText("Continue after reconnecting") + expect(requests).toBe(recover ? 2 : 3) + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(recovery).toEqual([]) + await page.screenshot({ path: test.info().outputPath("location-sync.png") }) + }) + } +} + +test("follows a live session move while the agent catalog is still loading", async ({ page }) => { + const recovery = recoveryRequests(page) + const directory = "/projects/old-tree" + const destination = "/projects/current-tree" + const sessionID = "ses_location_moved_while_loading" + const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" } + const requested = Promise.withResolvers() + const release = Promise.withResolvers() + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [session], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Follow the session move", time: { created: 1 } }], + }), + }) + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()) + if (url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory) { + requested.resolve() + await release.promise + } + return route.fallback() + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await requested.promise + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Keep this draft") + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + await transport.waitForConnection() + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok() + ) + }) + session.directory = destination + await transport.send({ + id: "evt_location_moved_while_loading", + type: "session.moved", + created: 2, + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { sessionID, location: { directory: destination }, projectID: fixture.project.id }, + }) + await resolved + const delayed = page.waitForResponse((response) => { + const url = new URL(response.url()) + return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === directory && response.ok() + }) + release.resolve() + await delayed + await expect(prompt).toBeEditable() + await expect(prompt).toHaveText("Keep this draft") + await expect(page.getByText("Follow the session move", { exact: true })).toBeVisible() + await expect(page.locator('[data-type="location-switched"]').getByText(destination, { exact: true })).toBeVisible() + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(recovery).toEqual([]) +}) + +test("refreshes a session moved during disconnection without losing the draft", async ({ page }) => { + const recovery = recoveryRequests(page) + const directory = "/projects/before-reconnect" + const destination = "/projects/after-reconnect" + const sessionID = "ses_location_reconnect" + const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Reconnected session" } + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [session], + fileList: () => [], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Resume in the current worktree", time: { created: 1 } }], + }), + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Draft before disconnect") + const connection = await transport.waitForConnection() + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && url.searchParams.get("location[directory]") === destination && response.ok() + ) + }) + session.directory = destination + await transport.close() + await transport.waitForConnection({ after: connection.id }) + await resolved + await expect(prompt).toBeEditable() + await expect(prompt).toHaveText("Draft before disconnect") + await expect(page.getByText("Resume in the current worktree", { exact: true })).toBeVisible() + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(recovery).toEqual([]) +}) + +test("ignores an old failed location read after reconnecting", async ({ page }) => { + const recovery = recoveryRequests(page) + const directory = "/projects/reconnected-tree" + const sessionID = "ses_location_stale_response" + const requested = Promise.withResolvers() + const release = Promise.withResolvers() + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [{ id: sessionID, projectID: fixture.project.id, directory }], + fileList: () => [], + pageMessages: () => ({ items: [] }), + }) + let requests = 0 + await page.route("**/api/location?**", async (route) => { + if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback() + requests++ + if (requests > 1) return route.fallback() + requested.resolve() + await release.promise + return route.fulfill({ + status: 500, + body: "", + headers: { "access-control-allow-origin": "*" }, + }) + }) + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + await requested.promise + const prompt = page.getByRole("textbox", { name: "Prompt", exact: true }) + await expect(prompt).toBeEditable() + await prompt.fill("Keep typing here") + const connection = await transport.waitForConnection() + const metadata = page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${sessionID}`) + const resolved = page.waitForResponse((response) => { + const url = new URL(response.url()) + return ( + url.pathname === "/api/location" && url.searchParams.get("location[directory]") === directory && response.ok() + ) + }) + await transport.close() + await transport.waitForConnection({ after: connection.id }) + await metadata + release.resolve() + await resolved + await expect(prompt).toBeEditable() + await expect(prompt).toHaveText("Keep typing here") + await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toHaveCount(0) + expect(requests).toBe(2) + expect(recovery).toEqual([]) +}) + +function recoveryRequests(page: Page) { + const requests: string[] = [] + page.on("request", (request) => { + const path = new URL(request.url()).pathname + if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path) + }) + return requests +} diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 1a5a98faf12..0b17dbdf4f0 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -194,6 +194,7 @@ async function resolveSessionDirectory(input: { return createWorktree({ api: input.serverSDK.api, + data: input.data, directory: input.projectDirectory, project: input.data.location.info({ directory: input.projectDirectory })?.project, branch: input.branch, diff --git a/packages/app/src/session/composer/location-missing.tsx b/packages/app/src/session/composer/location-missing.tsx deleted file mode 100644 index 25527f6fb80..00000000000 --- a/packages/app/src/session/composer/location-missing.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DockShell, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" -import { Menu } from "@opencode-ai/ui/menu" -import { getFilename } from "@opencode-ai/util/path" -import { createMutation } from "@tanstack/solid-query" -import { createEffect, createResource, For, Show } from "solid-js" -import { createStore } from "solid-js/store" -import { useLanguage } from "@/runtime/i18n/language" -import { useServerSDK } from "@/runtime/server/client" -import { useData } from "@/runtime/server/current" -import { showToast } from "@/shell/notifications/toast" -import { useDirectoryPicker } from "@/workspaces/selection/picker" -import { createWorktree } from "@/workspaces/create" - -export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) { - const language = useLanguage() - const sdk = useServerSDK() - const data = useData() - const dialog = useDialog() - const pickDirectory = useDirectoryPicker() - const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false }) - const project = () => data.project.get(props.projectID) - const [worktrees] = createResource( - () => (state.worktreesOpen ? props.projectID : undefined), - async (projectID) => { - try { - await sdk.api.worktree.refresh({ projectID }) - return await sdk.api.worktree.list({ projectID }) - } catch { - showToast({ variant: "error", title: language.t("session.location.worktreesFailed") }) - return [] - } - }, - // Seed latest so even the first fetch does not enter Suspense. - { initialValue: [] }, - ) - let button: HTMLButtonElement | undefined - - const move = createMutation(() => ({ - mutationFn: async (input: { sessionID: string; directory?: string }) => { - // A deleted worktree cannot resolve its own location. Create from the - // project's saved canonical checkout instead. - const current = project() - const destination = - input.directory ?? - (current && - (await createWorktree({ - api: sdk.api, - directory: current.canonical, - project: { id: current.id, canonical: current.canonical, directory: current.canonical }, - }))) - if (!destination) return - await sdk.api.session.move({ sessionID: input.sessionID, directory: destination }) - }, - onError: (error) => { - setState("restoreFocus", true) - showToast({ - variant: "error", - title: language.t("workspace.move.failed"), - description: error instanceof Error ? error.message : language.t("common.requestFailed"), - }) - }, - })) - - createEffect(() => { - if (!state.restoreFocus || move.isPending || dialog.active) return - setState("restoreFocus", false) - button?.focus() - }) - - function choose() { - if (move.isPending) return - const sessionID = props.sessionID - pickDirectory({ - server: sdk.server, - title: language.t("session.location.choose"), - onSelect: (result) => { - const directory = Array.isArray(result) ? result[0] : result - if (directory) move.mutate({ sessionID, directory }) - if (!directory) setState("restoreFocus", true) - }, - }) - } - - return ( -
- -
- -
-
{language.t("session.location.unavailable")}
-
{props.directory}
-
{language.t("session.location.description")}
-
-
-
- - - setState("worktreesOpen", open)}> - - {language.t("session.location.worktree")} - - - - move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}> - - {language.t("workspace.new")} - - - {language.t("common.loading")} - - item.strategy && item.directory !== props.directory)}> - {(worktree) => ( - move.mutate({ sessionID: props.sessionID, directory: worktree.directory })} - disabled={move.isPending} - > - - {getFilename(worktree.directory)} - - )} - - - - - - - -
- ) -} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 375cc771acb..2634b1e97d6 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view" import { makeEventListener } from "@solid-primitives/event-listener" import { useNavigate } from "@solidjs/router" -import { createEffect, createMemo, on, onMount, Show } from "solid-js" +import { createEffect, on, onMount } from "solid-js" import { Composer } from "@/composer/composer" import { createComposerModel, type ComposerModel } from "@/composer/model" import { useComposerState } from "@/composer/persistence" @@ -32,7 +32,6 @@ import { SessionQueuePanel } from "./queue-panel" import { resolveSessionComposerSelection } from "./selection" import { createSessionRequestModel } from "../requests/model" import { useSettings } from "@/settings/model" -import { SessionLocationMissing } from "./location-missing" export function createActiveSessionRegion(input: { session: SessionModel @@ -220,14 +219,6 @@ export function ActiveSessionComposerRegion(props: { onResponseSubmit: () => void }) { const settings = useSettings() - const location = useWorkspaceLocation() - const missing = createMemo(() => { - const error = location().error - const current = props.session.data.info()?.location - if (error && current?.directory === error.location.directory && current.workspaceID === error.location.workspaceID) - return current.directory - return undefined - }) const region = createSessionComposerRegionController({ state: props.model.region.state, parentID: props.session.data.parentID, @@ -256,26 +247,12 @@ export function ActiveSessionComposerRegion(props: { - -
- -
- - } - > - {(directory) => ( - - )} - +
+ +
+ +
+
} /> ) diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index db2b8a06929..9cdb3d07d22 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -116,7 +116,7 @@ function ResolvedTargetSessionRoute() { const current = createSessionResolution( () => params.id, () => data.session, - { children: true }, + { children: true, connected: () => server.ctx.sdk.connection.status() === "connected" }, ) const directory = createMemo(() => current()?.location.directory) @@ -133,7 +133,7 @@ function ResolvedTargetSessionRoute() { > }> {(value) => ( - + current()?.location.workspaceID}> diff --git a/packages/app/src/session/session-resolution.ts b/packages/app/src/session/session-resolution.ts index 357f14fffc2..7778636be6e 100644 --- a/packages/app/src/session/session-resolution.ts +++ b/packages/app/src/session/session-resolution.ts @@ -34,7 +34,7 @@ type Resolution = { id: string; store: SessionStore } & ( export function createSessionResolution( sessionID: () => string | undefined, sessions: () => SessionStore, - options?: { children?: boolean }, + options?: { children?: boolean; connected?: () => boolean }, ) { const cached = createMemo(() => { const id = sessionID() @@ -46,15 +46,15 @@ export function createSessionResolution( // Start independent reads before constructing the selected view, including // when its metadata is cached but its transcript has never been loaded. createRenderEffect( - on([sessionID, sessions] as const, ([id, store]) => { - if (!id) return + on([sessionID, sessions, () => options?.connected?.() ?? true] as const, ([id, store, connected]) => { + if (!id || !connected) return let stale = false onCleanup(() => { stale = true }) // The timeline owns message errors; metadata resolution stays independent. void store.message.sync(id).catch(() => undefined) - if (cached() && !options?.children) { + if (cached() && !options?.children && !options?.connected) { setStatus({ id, store, state: "settled" }) return } diff --git a/packages/app/src/session/timeline/session-workspace-menu.tsx b/packages/app/src/session/timeline/session-workspace-menu.tsx index 5c688c98f14..0ad08de184e 100644 --- a/packages/app/src/session/timeline/session-workspace-menu.tsx +++ b/packages/app/src/session/timeline/session-workspace-menu.tsx @@ -60,6 +60,7 @@ export function SessionWorkspaceMenu(props: { selection === "create" ? await createWorktree({ api: sdk.api, + data, directory: props.directory, project: data.location.info({ directory: props.directory })?.project, }) diff --git a/packages/app/src/workspaces/create.test.ts b/packages/app/src/workspaces/create.test.ts index 1b41cc59fde..61961f2ec13 100644 --- a/packages/app/src/workspaces/create.test.ts +++ b/packages/app/src/workspaces/create.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" +import { createData } from "@opencode-ai/client/solid" +import { createRoot } from "solid-js" import { createWorktree } from "./create" describe("worktree creation", () => { @@ -44,28 +46,44 @@ describe("worktree creation", () => { ), }) - expect( - await createWorktree({ - api, + await createRoot(async (dispose) => { + const data = createData({ + api: () => api, directory: input.directory, - project: input.cached ? project : undefined, - branch: "clone-only", - }), - ).toBe("/created") - expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({ - strategy: "git", - from: input.canonical, - branch: "clone-only", - directory: input.parent, + event: { on: () => () => {}, listen: () => () => {} }, + }) + try { + expect( + await createWorktree({ + api, + data, + directory: input.directory, + project: input.cached ? project : undefined, + branch: "clone-only", + }), + ).toBe("/created") + expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({ + strategy: "git", + from: input.canonical, + branch: "clone-only", + directory: input.parent, + }) + expect(requests.find((request) => request.method === "POST")?.url).toBe( + "http://localhost:3000/api/worktree/proj_clone", + ) + expect( + requests + .filter((request) => request.method === "GET") + .map((request) => new URL(request.url).searchParams.get("location[directory]")), + ).toEqual(input.cached ? ["/created"] : [input.directory, "/created"]) + expect(data.location.info({ directory: "/created" })).toEqual({ directory: "/created", project }) + const count = requests.length + await data.location.syncInfo({ directory: "/created" }) + expect(requests).toHaveLength(count) + } finally { + dispose() + } }) - expect(requests.find((request) => request.method === "POST")?.url).toBe( - "http://localhost:3000/api/worktree/proj_clone", - ) - expect( - requests - .filter((request) => request.method === "GET") - .map((request) => new URL(request.url).searchParams.get("location[directory]")), - ).toEqual(input.cached ? ["/created"] : [input.directory, "/created"]) }) test("does not fall back to a shared project when location lookup fails", async () => { @@ -81,10 +99,21 @@ describe("worktree creation", () => { ), }) - await expect(createWorktree({ api, directory: "/copies/repo" })).rejects.toMatchObject({ - reason: "UnexpectedStatus", - cause: { status: 503 }, + await createRoot(async (dispose) => { + const data = createData({ + api: () => api, + directory: "/copies/repo", + event: { on: () => () => {}, listen: () => () => {} }, + }) + try { + await expect(createWorktree({ api, data, directory: "/copies/repo" })).rejects.toMatchObject({ + reason: "UnexpectedStatus", + cause: { status: 503 }, + }) + expect(requests.map((request) => request.method)).toEqual(["GET"]) + } finally { + dispose() + } }) - expect(requests.map((request) => request.method)).toEqual(["GET"]) }) }) diff --git a/packages/app/src/workspaces/create.ts b/packages/app/src/workspaces/create.ts index 9cc66e80272..f487bac4278 100644 --- a/packages/app/src/workspaces/create.ts +++ b/packages/app/src/workspaces/create.ts @@ -1,8 +1,10 @@ import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise" +import type { Data } from "@opencode-ai/client/solid" import { getDirectory } from "@opencode-ai/util/path" export async function createWorktree(input: { api: Pick + data: Pick directory: string project?: LocationGetOutput["project"] branch?: string @@ -15,6 +17,7 @@ export async function createWorktree(input: { branch: input.branch, directory: getDirectory(project.canonical), }) - await input.api.location.get({ location: { directory: created.directory } }) + // Populate the client cache before the destination session mounts. + await input.data.location.syncInfo({ directory: created.directory }) return created.directory } diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 0a4c0add45d..2bd83bc3e27 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -1,6 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise" -import { type Accessor, createEffect, createMemo, createSignal } from "solid-js" +import { retry } from "@opencode-ai/util/retry" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { type LocationContext, useServerSDK } from "@/runtime/server/client" import { useData } from "@/runtime/server/current" export type { LocationContext } from "@/runtime/server/client" @@ -8,7 +9,6 @@ export type { LocationContext } from "@/runtime/server/client" export type WorkspaceLocation = LocationContext & { readonly ref: LocationRef readonly current: LocationGetOutput | undefined - readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined } const context = createSimpleContext({ @@ -16,29 +16,29 @@ const context = createSimpleContext({ init: (props: { directory: string | Accessor; workspaceID?: string | Accessor }) => { const serverSDK = useServerSDK() const data = useData() - const ref = createMemo(() => ({ - directory: typeof props.directory === "function" ? props.directory() : props.directory, - workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID, - })) + const ref = createMemo( + () => ({ + directory: typeof props.directory === "function" ? props.directory() : props.directory, + workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID, + }), + undefined, + { + equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID, + }, + ) const current = createMemo(() => data.location.info(ref())) - const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>() - let generation = 0 createEffect(() => { const location = ref() - if (serverSDK.connection.status() !== "connected") return - const attempt = ++generation - setError(undefined) - void data.location.sync(location).catch((cause) => { - const latest = ref() - if ( - generation !== attempt || - latest.directory !== location.directory || - latest.workspaceID !== location.workspaceID - ) - return - setError({ location, cause }) + let stale = false + onCleanup(() => { + stale = true }) + if (serverSDK.connection.status() !== "connected") return + // A failed sync does not prove the directory is missing. Keep recovery local to reads. + void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), { + retryIf: () => !stale, + }).catch(() => undefined) }) const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory)) @@ -46,7 +46,6 @@ const context = createSimpleContext({ ...location(), ref: ref(), current: current(), - error: error(), })) }, }) diff --git a/packages/app/test-browser/session-resolution.test.ts b/packages/app/test-browser/session-resolution.test.ts index 614c5c2de6f..1c71c36d55d 100644 --- a/packages/app/test-browser/session-resolution.test.ts +++ b/packages/app/test-browser/session-resolution.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" +import { createStore } from "solid-js/store" import { createSessionResolution } from "@/session/session-resolution" type Session = { id: string; directory: string } @@ -34,9 +35,10 @@ function createFixture(initial: Record = {}) { }, }, }, - settle(id: string) { - setCache({ ...cache(), [id]: sessionOf(id) }) + settle(id: string, directory = `/dir/${id}`) { + setCache({ ...cache(), [id]: { id, directory } }) deferred.get(id)?.resolve(undefined) + deferred.delete(id) }, fail(id: string, error: unknown) { deferred.get(id)?.reject(error) @@ -59,6 +61,38 @@ const flush = async () => { await Promise.resolve() } +test("refreshes the current session on reconnect while keeping cached content visible", async () => { + await createRoot(async (dispose) => { + const fixture = createFixture({ ses_a: sessionOf("ses_a") }) + const [connection, setConnection] = createStore({ connected: false }) + const current = createSessionResolution( + () => "ses_a", + () => fixture.sessions, + { connected: () => connection.connected }, + ) + + expect(current()).toEqual(sessionOf("ses_a")) + expect(fixture.resolves).toEqual([]) + await flush() + setConnection("connected", true) + expect(fixture.resolves).toEqual(["ses_a"]) + fixture.settle("ses_a") + await flush() + + setConnection("connected", false) + expect(current()).toEqual(sessionOf("ses_a")) + expect(fixture.resolves).toEqual(["ses_a"]) + setConnection("connected", true) + expect(fixture.resolves).toEqual(["ses_a", "ses_a"]) + expect(current()).toEqual(sessionOf("ses_a")) + expect(fixture.messages.syncs).toEqual(["ses_a", "ses_a"]) + fixture.settle("ses_a", "/worktrees/moved") + await flush() + expect(current()?.directory).toBe("/worktrees/moved") + dispose() + }) +}) + test("starts metadata and messages in parallel once the route has a session ID", async () => { await createRoot(async (dispose) => { const fixture = createFixture()