fix(app): recover only confirmed missing worktree locations

A location.sync failure can come from transient server errors and /api/location boots the source before it can determine whether the path exists. Probe filesystem NotFound outside the location middleware, ignore stale and failed probes, and show the recovery dock only after a confirmed missing source. Keep session history and draft, and create replacement worktrees from the known project root.
This commit is contained in:
Brendonovich
2026-09-30 08:05:44 +00:00
parent 7f008d40cb
commit 1d254848d0
19 changed files with 520 additions and 80 deletions
@@ -29,6 +29,14 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
if (recover && requests > 1) return route.fallback()
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
})
await page.route("**/api/location/probe?**", (route) =>
route.fulfill({
status: 500,
json: { _tag: "ServiceUnavailableError", message: "Probe failed" },
headers: { "access-control-allow-origin": "*" },
}),
)
const probe = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/location/probe")
const failure = page.waitForResponse(
(response) => new URL(response.url()).pathname === endpoint && response.status() === 500,
)
@@ -41,6 +49,7 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
)
})
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
expect((await probe).status()).toBe(500)
await failure
await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible()
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
@@ -22,23 +22,28 @@ test("keeps a session in a removed worktree readable and movable", async ({ page
})
await page.route("**/api/**", (route) => {
const url = new URL(route.request().url())
if (url.pathname === "/api/location/probe" && url.searchParams.get("directory") === missing)
return route.fulfill({ json: { exists: false }, headers: { "access-control-allow-origin": "*" } })
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": "*" } })
return route.fulfill({
status: 500,
json: { _tag: "ServiceUnavailableError", message: "Unable to boot removed location" },
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 expect(page.getByRole("status")).toContainText("Session location unavailable")
await expect(page.getByRole("status")).toContainText(missing)
await expect(prompt).toHaveCount(0)
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toBeEnabled()
await transport.waitForConnection()
await page.getByRole("button", { name: "Session details" }).click()
await page.getByRole("button", { name: "Local repository" }).click()
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
const create = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "POST",
{ timeout: 10_000 },
@@ -59,6 +64,193 @@ test("keeps a session in a removed worktree readable and movable", async ({ page
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("Session location unavailable", { exact: true })).toHaveCount(0)
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible()
})
test("preserves a draft when a worktree disappears and resumes after choosing a directory", async ({ page }) => {
const source = "/projects/draft-worktree"
const destination = "/projects/restored"
const sessionID = "ses_draft_recovery"
const session = { id: sessionID, projectID: fixture.project.id, directory: source, title: "Draft recovery" }
const transport = await installSseTransport(page, { server: fixture.serverKey })
let exists = true
await mockOpenCodeServer(page, {
directory: destination,
project: { ...fixture.project, worktree: destination },
provider: fixture.provider,
sessions: [session],
fileList: () => [],
pageMessages: () => ({
items: [{ id: "msg_draft", type: "user", text: "Saved draft history", time: { created: 1 } }],
}),
})
await page.route("**/api/location/probe?**", (route) =>
route.fulfill({ json: { exists }, headers: { "access-control-allow-origin": "*" } }),
)
let moves = 0
await page.route(`**/api/session/${sessionID}/move`, (route) => {
moves++
if (moves === 1)
return route.fulfill({
status: 400,
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
headers: { "access-control-allow-origin": "*" },
})
return route.fulfill({ status: 204, body: "" })
})
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("A draft to keep after moving")
const connection = await transport.waitForConnection()
const missing = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/location/probe" && url.searchParams.get("directory") === source && response.ok()
})
exists = false
await transport.close()
await transport.waitForConnection({ after: connection.id })
await missing
await expect(page.getByRole("status")).toContainText("Session location unavailable")
await expect(prompt).toHaveCount(0)
await expect(page.getByText("Saved draft history", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Choose directory", exact: true }).click()
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
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)
const move = page.waitForRequest(
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
)
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
expect((await move).postDataJSON()).toEqual({ directory: destination })
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toBeEnabled()
await expect(prompt).toHaveCount(0)
expect(moves).toBe(1)
await page.getByRole("button", { name: "Choose directory", exact: true }).click()
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)
const secondMove = page.waitForRequest(
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
)
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
expect((await secondMove).postDataJSON()).toEqual({ directory: destination })
expect(moves).toBe(2)
session.directory = destination
exists = true
await transport.send({
id: "evt_draft_recovery_moved",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await expect(prompt).toBeEditable()
await expect(prompt).toHaveText("A draft to keep after moving")
await expect(page.getByText("Saved draft history", { exact: true })).toBeVisible()
})
test("ignores a stale missing result after the session moves", async ({ page }) => {
const source = "/projects/old-worktree"
const destination = "/projects/new-worktree"
const sessionID = "ses_stale_probe"
const session = { id: sessionID, projectID: fixture.project.id, directory: source, title: "Moving session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
provider: fixture.provider,
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/location/probe?**", async (route) => {
if (new URL(route.request().url()).searchParams.get("directory") === source) {
requested.resolve()
await release.promise
return route.fulfill({ json: { exists: false }, headers: { "access-control-allow-origin": "*" } })
}
return route.fulfill({ json: { exists: true }, 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("Draft in new worktree")
await transport.waitForConnection()
const next = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/location/probe" && url.searchParams.get("directory") === destination && response.ok()
})
session.directory = destination
await transport.send({
id: "evt_stale_probe_moved",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await next
const old = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/location/probe" && url.searchParams.get("directory") === source && response.ok()
})
release.resolve()
await old
await expect(prompt).toBeEditable()
await expect(prompt).toHaveText("Draft in new worktree")
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
})
test("moves a removed-worktree session into an existing worktree", async ({ page }) => {
const source = "/projects/deleted-worktree"
const destination = "/projects/existing-worktree"
const sessionID = "ses_existing_worktree"
const session = { id: sessionID, projectID: fixture.project.id, directory: source, title: "Existing worktree" }
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: { ...fixture.project, sandboxes: [destination] },
provider: fixture.provider,
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/location/probe?**", (route) =>
route.fulfill({
json: { exists: new URL(route.request().url()).searchParams.get("directory") !== source },
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}`)
await expect(page.getByRole("status")).toContainText("Session location unavailable")
await transport.waitForConnection()
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
const option = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
await expect(option).toBeVisible()
const move = page.waitForRequest(
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
)
await option.click()
expect((await move).postDataJSON()).toEqual({ directory: destination })
session.directory = destination
await transport.send({
id: "evt_existing_worktree_moved",
type: "session.moved",
created: 2,
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
})
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
})
+4 -4
View File
@@ -17,13 +17,13 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
const local = useLocal()
const sdk = useWorkspaceLocation()
const data = useData()
const providers = useProviders(() => sdk().catalogDirectory)
const providers = useProviders(() => sdk().directory)
const view = layout.view(input.sessionKey)
return createMemo<ComposerControls>(() => {
return {
agents: {
available: normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []),
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
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<string>; mo
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().catalogDirectory }) === undefined) ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
session: {
@@ -67,7 +67,7 @@ export function createComposerModelSelection(input: {
},
),
)
const providers = useProviders(() => sdk().catalogDirectory)
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
@@ -9,13 +9,13 @@ export function useConfiguredModel() {
const server = useServerSDK()
createEffect(
on(
() => [location().catalogDirectory, server.connection.status()] as const,
() => [location().directory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().catalogDirectory })
const documents = () => data.location.config.list({ directory: location().directory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
@@ -80,14 +80,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useWorkspaceLocation()
const data = useData()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().catalogDirectory)
const providers = useProviders(() => sdk().directory)
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().catalogDirectory }) ?? []).filter(
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
(item) => item.mode !== "subagent" && !item.hidden,
),
)
@@ -0,0 +1,135 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { getFilename } from "@opencode/util/path"
import { useMutation } 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 type MissingLocation = { sessionID: string; projectID: string; directory: string }
export function SessionLocationMissing(props: MissingLocation) {
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 []
}
},
{ initialValue: [] },
)
let button: HTMLButtonElement | undefined
const move = useMutation(() => ({
mutationFn: async (input: { sessionID: string; directory?: string }) => {
const current = project()
if (!current) throw new Error(language.t("workspace.move.failed"))
const destination =
input.directory ??
(await createWorktree({
api: sdk.api,
data,
directory: current.canonical,
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
}))
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()
})
const 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 (
<div data-component="session-location-missing">
<DockShell class="flex flex-col gap-2 p-3">
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
<div class="min-w-0 flex flex-col gap-1">
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
<div class="text-text-base">{language.t("session.location.description")}</div>
</div>
</div>
</DockShell>
<DockTray class="flex flex-wrap justify-end gap-2 p-2">
<Show when={project()?.vcs === "git"}>
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
{language.t("session.location.worktree")}
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
<Menu.Item onSelect={() => move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={worktrees.loading}>
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
</Show>
<For each={worktrees.latest.filter((item) => item.strategy && item.directory !== props.directory)}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ sessionID: props.sessionID, directory: worktree.directory })}
disabled={move.isPending}
>
<Icon name="workspace-isolated" />
<span class="truncate">{getFilename(worktree.directory)}</span>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
</Button>
</DockTray>
</div>
)
}
+25 -13
View File
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js"
import { Composer } from "@/composer/composer"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
@@ -28,6 +28,7 @@ import { createSessionComposerController, type SessionComposerController } from
import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { SessionLocationMissing, type MissingLocation } from "./location-missing"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -216,22 +217,33 @@ export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegi
export function ActiveSessionComposerRegion(props: {
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
missing?: Accessor<MissingLocation | undefined>
}) {
return (
<SessionComposerRegion
controller={props.model.region}
controller={{
...props.model.region,
showComposer: () => props.model.region.showComposer() || !!props.missing?.(),
}}
composer={
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</div>
</div>
<Show
when={props.missing?.()}
fallback={
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.undoing()}
suggestionBoundary={props.suggestionBoundary}
/>
</div>
</div>
}
>
{(missing) => <SessionLocationMissing {...missing()} />}
</Show>
}
/>
)
+28 -38
View File
@@ -1,6 +1,5 @@
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"
@@ -19,7 +18,6 @@ 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"
@@ -30,59 +28,51 @@ import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
import { SessionScreen } from "./screen"
import { PreparingComposer } from "./preparing-composer"
import type { MissingLocation } from "./composer/location-missing"
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const data = useData()
const server = useServer()
const tabs = useTabs()
const [locationState, setLocationState] = createStore({ unavailableSource: "" })
const [locationState, setLocationState] = createStore<{ missing?: MissingLocation }>({})
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
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
const directory = session?.location.directory
const project = session && data.project.get(session.projectID)
const connected = server.ctx.sdk.connection.status() === "connected"
setLocationState("missing", undefined)
if (!session || !directory || !project || !connected || directory === project.canonical) 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)
void server.ctx.sdk.api.location.probe({ directory }).then(
(result) => {
if (!stale && !result.exists)
setLocationState("missing", { sessionID: session.id, projectID: project.id, directory })
},
() => undefined,
)
})
const catalogDirectory = createMemo(() => {
const missing = 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
const state = locationState.missing
if (!state || !session || session.id !== state.sessionID || session.location.directory !== state.directory) return
if (server.ctx.sdk.connection.status() !== "connected") return
return state
})
return (
<>
<MarkSessionNotificationsViewed sessionID={() => params.id} />
<ModelsProvider directory={catalogDirectory}>
<ModelsProvider directory={directory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<Show
when={tabs.pendingSession(server.key, params.id)}
fallback={<ResolvedTargetSessionRoute catalogDirectory={catalogDirectory} />}
fallback={<ResolvedTargetSessionRoute missing={missing} />}
>
{(pending) => <PreparingSession sessionID={params.id} pending={pending()} />}
</Show>
@@ -153,7 +143,7 @@ function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serv
)
}
function ResolvedTargetSessionRoute(props: { catalogDirectory: () => string | undefined }) {
function ResolvedTargetSessionRoute(props: { missing: () => MissingLocation | undefined }) {
const params = useParams<{ id: string }>()
const server = useServer()
const tabs = useTabs()
@@ -178,9 +168,9 @@ function ResolvedTargetSessionRoute(props: { catalogDirectory: () => string | un
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value} catalogDirectory={props.catalogDirectory}>
<SessionUIProvider directory={value()} providerDirectory={props.catalogDirectory()} server={server.key}>
<TargetSessionPage />
<LocationProvider directory={value}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage missing={props.missing} />
</SessionUIProvider>
</LocationProvider>
)}
@@ -205,7 +195,7 @@ function SessionStatePanel(props: ParentProps) {
)
}
function TargetSessionPage() {
function TargetSessionPage(props: { missing: () => MissingLocation | undefined }) {
return (
// These providers select their scoped state reactively and retain bounded caches,
// so keep their owners alive while navigating between workspaces on this server.
@@ -213,7 +203,7 @@ function TargetSessionPage() {
<FileProvider>
<ComposerPersistenceProvider>
<CommentsProvider>
<SessionPage />
<SessionPage missing={props.missing} />
</CommentsProvider>
</ComposerPersistenceProvider>
</FileProvider>
@@ -221,9 +211,9 @@ function TargetSessionPage() {
)
}
function SessionPage() {
function SessionPage(props: { missing: () => MissingLocation | undefined }) {
const session = useSessionModel()
return <SessionScreen session={session} />
return <SessionScreen session={session} missing={props.missing} />
}
function MarkSessionNotificationsViewed(props: { sessionID: () => string | undefined }) {
+12 -4
View File
@@ -9,6 +9,7 @@ import {
createEffect,
createComputed,
on,
type Accessor,
} from "solid-js"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode/ui/resize-handle"
@@ -36,6 +37,7 @@ import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
import { createSessionBtw } from "./btw/model"
import type { MissingLocation } from "./composer/location-missing"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -47,19 +49,23 @@ const SessionSummaryPanel = lazy(async () => {
return { default: SessionSummaryPanel }
})
export function SessionScreen(props: { session: SessionModel }) {
export function SessionScreen(props: { session: SessionModel; missing?: Accessor<MissingLocation | undefined> }) {
// The timeline cache captures its owner when created, so link handling must be provided above it.
const browser = createSessionBrowser(props.session)
return (
<ArtifactOpenerProvider session={props.session} browser={browser}>
<ArtifactMarkdownProvider>
<SessionScreenContent session={props.session} browser={browser} />
<SessionScreenContent session={props.session} browser={browser} missing={props.missing} />
</ArtifactMarkdownProvider>
</ArtifactOpenerProvider>
)
}
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
function SessionScreenContent(props: {
session: SessionModel
browser: ReturnType<typeof createSessionBrowser>
missing?: Accessor<MissingLocation | undefined>
}) {
const session = props.session
const browser = props.browser
const server = useServer()
@@ -323,7 +329,9 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
</div>
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
{(model) => (
<ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} missing={props.missing} />
)}
</Show>
</>
)
@@ -14,7 +14,6 @@ import { readLocalImage } from "@/runtime/server/image"
export function SessionUIProvider(
props: ParentProps<{
directory: string
providerDirectory?: string
server: ServerConnection.Key
}>,
) {
@@ -40,7 +39,7 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const providers = useProviders(() => props.providerDirectory ?? directory())
const providers = useProviders(directory)
const sessionUIData = createMemo(() => ({
provider: providers.ready()
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
+1 -7
View File
@@ -9,16 +9,11 @@ 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<string>
workspaceID?: string | Accessor<string | undefined>
catalogDirectory?: Accessor<string | undefined>
}) => {
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
@@ -58,7 +53,6 @@ const context = createSimpleContext({
...location(),
ref: ref(),
current: current(),
catalogDirectory: props.catalogDirectory?.() ?? ref().directory,
}))
},
})
+5
View File
@@ -60,6 +60,10 @@ export interface ServerApi<E = never> {
readonly connect: ServerConnectOperation<E>
}
export type LocationProbeInput = { readonly directory: string }
export type LocationProbeOutput = { readonly exists: boolean }
export type LocationProbeOperation<E = never> = (input: LocationProbeInput) => Effect.Effect<LocationProbeOutput, E>
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type LocationGetOutput = Location.PublicInfo
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
@@ -68,6 +72,7 @@ export type LocationReloadOutput = void
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
export interface LocationApi<E = never> {
readonly probe: LocationProbeOperation<E>
readonly get: LocationGetOperation<E>
readonly reload: LocationReloadOperation<E>
}
@@ -9,6 +9,8 @@ import type {
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationProbeInput,
LocationProbeOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -301,6 +303,11 @@ const adaptGroupServer = (raw: RawClient["server.server"]) => ({
connect: EndpointServerConnect(raw),
})
const EndpointLocationProbe = (raw: RawClient["server.location"]) => (input: LocationProbeInput) =>
preserveEffect<LocationProbeOutput>()(
raw["location.probe"]({ query: { directory: input["directory"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -310,6 +317,7 @@ const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
probe: EndpointLocationProbe(raw),
get: EndpointLocationGet(raw),
reload: EndpointLocationReload(raw),
})
@@ -3,6 +3,8 @@ import type {
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationProbeInput,
LocationProbeOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -440,6 +442,18 @@ export function make(options: ClientOptions) {
),
},
location: {
probe: (input: LocationProbeInput, requestOptions?: RequestOptions) =>
request<LocationProbeOutput>(
{
method: "GET",
path: `/api/location/probe`,
query: { directory: input["directory"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
request<LocationGetOutput>(
{
@@ -2742,6 +2742,10 @@ export type ServerConnectInput = { readonly code: { readonly code: string }["cod
export type ServerConnectOutput = PairingSession
export type LocationProbeInput = { readonly directory: { readonly directory: string }["directory"] }
export type LocationProbeOutput = { exists: boolean }
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+17
View File
@@ -11,6 +11,10 @@ export const LocationQuery = Schema.Struct({
),
}).annotate({ identifier: "LocationQuery" })
const ProbeQuery = Schema.Struct({
directory: Schema.String,
}).annotate({ identifier: "LocationProbeQuery" })
export const locationQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
@@ -32,6 +36,19 @@ export const makeLocationGroup = <LocationId extends HttpApiMiddleware.AnyId, Lo
locationMiddleware: Context.Key<LocationId, LocationService>,
) =>
HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.probe", "/api/location/probe", {
query: ProbeQuery,
success: Schema.Struct({ exists: Schema.Boolean }),
}).annotateMerge(
OpenApi.annotations({
identifier: "location.probe",
summary: "Probe location path",
description:
"Check if an explicit path exists without loading its location. Only filesystem NotFound returns false; other filesystem failures do not imply absence.",
}),
),
)
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
+9
View File
@@ -1,6 +1,7 @@
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { ServiceUnavailableError } from "@opencode/protocol/errors"
import { FSUtil } from "@opencode/util/fs-util"
import { Cause, Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -8,7 +9,15 @@ import { Api } from "../api"
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
return handlers
.handle("location.probe", (ctx) =>
fs.stat(ctx.query.directory).pipe(
Effect.as({ exists: true }),
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed({ exists: false })),
Effect.orDie,
),
)
.handle(
"location.get",
Effect.fn(function* () {
+3
View File
@@ -22,6 +22,7 @@ import { ShellSelect } from "@opencode/core/shell/select"
import { Job } from "@opencode/core/job"
import { Mcp } from "@opencode/core/mcp/index"
import { Global } from "@opencode/util/global"
import { FSUtil } from "@opencode/util/fs-util"
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { LocationActivity } from "@opencode/core/location-activity"
@@ -51,6 +52,7 @@ import type { ServerOptions } from "./options"
const applicationServiceNodes = [
Global.node,
FSUtil.node,
Database.node,
Bus.node,
EventLogger.node,
@@ -168,6 +170,7 @@ function makeRoutes<AuthError, AuthServices>(
PermissionSaved.Service,
PluginUpdate.Service,
Project.Service,
FSUtil.Service,
WellKnown.Service,
)(context),
),
@@ -0,0 +1,41 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
it.live("probes a directory without booting its location or masking a missing path", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const existing = path.join(tmp.path, "existing")
const missing = path.join(tmp.path, "removed")
const file = path.join(tmp.path, "file")
const loop = path.join(tmp.path, "loop")
yield* Effect.promise(async () => {
await fs.mkdir(existing)
await fs.writeFile(file, "content")
await fs.symlink(loop, loop)
})
const server = yield* startServer(tmp.path)
const probe = (directory?: string) =>
Effect.promise(async () => {
const url = new URL("/api/location/probe", server.base)
if (directory !== undefined) url.searchParams.set("directory", directory)
const response = await fetch(url, { headers: server.headers })
return { status: response.status, body: await response.json() }
})
expect(yield* probe(existing)).toEqual({ status: 200, body: { exists: true } })
expect(yield* probe(missing)).toEqual({ status: 200, body: { exists: false } })
expect(yield* probe(file)).toEqual({ status: 200, body: { exists: true } })
expect((yield* probe(path.join(file, "child"))).status).toBe(500)
expect((yield* probe(loop)).status).toBe(500)
expect((yield* probe()).status).toBe(400)
const loaded = yield* Effect.promise(async () =>
(await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })).json(),
)
expect(loaded).toEqual([])
}),
)