diff --git a/packages/app/e2e/regression/session-location-sync.spec.ts b/packages/app/e2e/regression/session-location-sync.spec.ts index 1f6a96c2f93a..4fd9cd55baad 100644 --- a/packages/app/e2e/regression/session-location-sync.spec.ts +++ b/packages/app/e2e/regression/session-location-sync.spec.ts @@ -66,6 +66,72 @@ for (const endpoint of ["/api/location", "/api/agent"]) { } } +test("replaces the composer when the location is not found and recovers by moving to another worktree", async ({ + page, +}) => { + const directory = "/projects/deleted-worktree" + const destination = "/projects/existing-worktree" + const sessionID = "ses_location_not_found" + const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" } + const moves: unknown[] = [] + const transport = await installSseTransport(page, { server: fixture.serverKey }) + await mockOpenCodeServer(page, { + directory: fixture.directory, + project: fixture.project, + provider: fixture.provider, + sessions: [session], + fileList: () => [], + worktrees: [{ directory: fixture.directory }, { directory: destination, strategy: "git" }], + pageMessages: () => ({ + items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }], + }), + }) + let requests = 0 + await page.route("**/api/location?**", (route) => { + if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback() + requests++ + + return route.fulfill({ + status: 404, + // SAFETY: the server's wire body for a missing Location, which the client decodes into its own error. + // oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above + json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` }, + headers: { "access-control-allow-origin": "*" }, + }) + }) + 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: 204, headers: { "access-control-allow-origin": "*" } }) + }) + + await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`) + const status = page.getByRole("status").filter({ hasText: "Session location unavailable" }) + await expect(status).toContainText(directory) + await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible() + await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0) + // A typed not-found answer is final; retrying cannot succeed until the folder changes. + expect(requests).toBe(1) + + await transport.waitForConnection() + await page.getByRole("button", { name: "Choose worktree", exact: true }).click() + await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click() + await expect.poll(() => moves).toEqual([{ directory: destination }]) + + session.directory = destination + await transport.send({ + id: "evt_location_not_found_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(status).toHaveCount(0) + await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible() +}) + test("follows a live session move while the agent catalog is still loading", async ({ page }) => { const recovery = recoveryRequests(page) const directory = "/projects/old-tree" diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 36d2055297b4..fe29d7ad0ce2 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -2,6 +2,13 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native" export const dict = { ...DESKTOP_NATIVE_ENGLISH, + "session.location.unavailable": "Session location unavailable", + "session.location.description": "Choose another directory to continue this session.", + "session.location.choose": "Choose directory", + "session.location.worktree": "Choose worktree", + "session.location.worktreesFailed": "Failed to load worktrees", + "session.location.moving": "Moving session…", + "session.location.moveFailed": "Failed to move session", "server.action.authenticate": "Authenticate", "server.status.connecting": "Connecting over SSH…", "server.status.authentication": "Authentication required", diff --git a/packages/app/src/session/composer/location-missing.tsx b/packages/app/src/session/composer/location-missing.tsx new file mode 100644 index 000000000000..c6ae1db1433f --- /dev/null +++ b/packages/app/src/session/composer/location-missing.tsx @@ -0,0 +1,142 @@ +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, sameDirectory } 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 { useData, useServer } from "@/runtime/server/current" +import { showToast } from "@/shell/notifications/toast" +import { createWorktree } from "@/workspaces/create" +import { useDirectoryPicker } from "@/workspaces/selection/picker" + +export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) { + const language = useLanguage() + const server = useServer() + 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) => { + await server.ctx.sync.worktrees.refresh(projectID) + const items = await server.ctx.sync.worktrees.list(projectID) + + if (!items) showToast({ variant: "error", title: language.t("session.location.worktreesFailed") }) + + return items ?? [] + }, + // Seed latest so even the first fetch does not enter Suspense. + { initialValue: [] }, + ) + + const otherWorktrees = () => + worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory)) + + let button: HTMLButtonElement | undefined + + const move = useMutation(() => ({ + mutationFn: async (input: { directory?: string }) => { + // The missing worktree cannot resolve its own Location, so create from the project's saved checkout. + const current = project() + + const destination = + input.directory ?? + (current && + (await createWorktree({ + api: server.ctx.sdk.api, + data, + directory: current.canonical, + project: { id: current.id, canonical: current.canonical, directory: current.canonical }, + }))) + + if (!destination) return + await server.ctx.sdk.api.session.move({ sessionID: props.sessionID, directory: destination }) + }, + onError: (error) => { + setState("restoreFocus", true) + showToast({ + variant: "error", + title: language.t("session.location.moveFailed"), + 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 + pickDirectory({ + server: server.conn, + title: language.t("session.location.choose"), + onSelect: (result) => { + const selected = Array.isArray(result) ? result[0] : result + + if (selected) move.mutate({ directory: selected }) + + if (!selected) 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({})} disabled={move.isPending}> + + {language.t("workspace.new")} + + + {language.t("common.loading")} + + + {(worktree) => ( + move.mutate({ 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 5a93a4427abb..20501999a689 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -1,10 +1,10 @@ import type { SessionUserActions } from "@opencode/session-ui/message" -import { getFilename } from "@opencode/util/path" +import { getFilename, sameDirectory } from "@opencode/util/path" 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" @@ -32,6 +32,7 @@ import { SessionQueuePanel } from "./queue-panel" import { resolveSessionComposerSelection } from "./selection" import { createSessionRequestModel } from "../requests/model" import type { Region } from "@/runtime/extension/panels" +import { SessionLocationMissing } from "./location-missing" export function createActiveSessionRegion(input: { session: SessionModel @@ -285,24 +286,51 @@ export function createActiveSessionRegion(input: { export type ActiveSessionRegionModel = ReturnType export function ActiveSessionComposerRegion(props: { + session: SessionModel model: SessionComposerController suggestionBoundary: () => HTMLElement | undefined }) { + const location = useWorkspaceLocation() + + // Only the server's LocationNotFoundError for the session's own Location replaces the composer. + const missing = createMemo(() => { + const ref = location().missing + const info = props.session.data.info() + + if (!ref || !info || !sameDirectory(info.location.directory, ref.directory)) return + + return { directory: info.location.directory, sessionID: info.id, projectID: info.projectID } + }) + return ( - -
- + +
+ +
+
+ } + > + {(current) => ( + - - + )} + } /> ) diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index e0aaddb44411..a333c2d66757 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -139,7 +139,7 @@ function ResolvedTargetSessionRoute() { > }> {(value) => ( - + diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 1be47c1482a3..137f2e18529d 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -341,7 +341,9 @@ function SessionScreenContent(props: { - {(model) => } + {(model) => ( + + )} ) diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 4c09b2130317..4b24c082dcb7 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -1,33 +1,40 @@ import { createSimpleContext } from "@opencode/ui/context" -import type { LocationGetOutput, LocationRef } from "@opencode/client/promise" +import { isLocationNotFoundError, type LocationGetOutput, type LocationRef } from "@opencode/client/promise" import { retry } from "@opencode/util/retry" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { type LocationContext, useServerSDK } from "@/runtime/server/client" import { useData, useServer } from "@/runtime/server/current" + export type { LocationContext } from "@/runtime/server/client" export type WorkspaceLocation = LocationContext & { readonly ref: LocationRef readonly current: LocationGetOutput | undefined + /** Set only when the server reports that this exact Location's folder does not exist. */ + readonly missing: LocationRef | undefined } const context = createSimpleContext({ name: "Location", - init: (props: { directory: string | Accessor; workspaceID?: string | Accessor }) => { + init: (props: { directory: string; workspaceID?: string }) => { const serverSDK = useServerSDK() const server = useServer() const data = useData() + const ref = createMemo( () => ({ - directory: typeof props.directory === "function" ? props.directory() : props.directory, - workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID, + directory: props.directory, + workspaceID: props.workspaceID, }), undefined, { equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID, }, ) + const current = createMemo(() => data.location.info(ref())) + const [state, setState] = createStore<{ missing?: LocationRef }>({}) createEffect(() => { const location = ref() @@ -35,27 +42,37 @@ const context = createSimpleContext({ onCleanup(() => { stale = true }) + setState("missing", undefined) + if (serverSDK.connection.status() !== "connected") return - // A failed sync does not prove the directory is missing. Keep recovery local to reads. + // Generic sync failures do not prove the directory is missing; only the server's typed + // LocationNotFoundError does, and retrying it cannot succeed until the folder changes. void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), { - retryIf: () => !stale, - }).catch(() => undefined) + retryIf: (error) => !stale && !isLocationNotFoundError(error), + }).catch((error) => { + if (stale || !isLocationNotFoundError(error)) return + setState("missing", location) + }) }) createEffect(() => { const id = current()?.project.id + if (!id || serverSDK.connection.status() !== "connected") return // Showing a Location is the demand for its project's worktree inventory (workspace styling, picker). void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id)) }) const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory)) + return createMemo(() => ({ ...location(), ref: ref(), current: current(), + missing: state.missing, })) }, }) export const useWorkspaceLocation: () => Accessor = context.use + export const LocationProvider = context.provider diff --git a/packages/app/src/workspaces/selection/dialog.tsx b/packages/app/src/workspaces/selection/dialog.tsx index 0185cac40b38..69eae80f029d 100644 --- a/packages/app/src/workspaces/selection/dialog.tsx +++ b/packages/app/src/workspaces/selection/dialog.tsx @@ -49,10 +49,12 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { const dialog = useDialog() const language = useLanguage() const policy = pickerMode(props.mode ?? "directory", props.start) + const action = { file: language.t("dialog.directory.action.selectFile"), directory: language.t("dialog.directory.action.selectFolder"), } + const [root, setRoot] = createSignal("") const [input, setInput] = createSignal("") const [selected, setSelected] = createSignal("") @@ -74,31 +76,42 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { () => sdk.api.location.get().catch(() => undefined), { initialValue: undefined }, ) + const home = createMemo(() => sync.data.path.home || "") + const location = createMemo(() => { - const current = props.location ?? fallbackPath() + const current = props.location ?? fallbackPath.latest + return current ? { directory: current.directory } : undefined }) + const start = createMemo( () => props.start || sync.data.path.home || props.location?.directory || sync.data.path.directory || - fallbackPath()?.directory, + fallbackPath.latest?.directory, ) + const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() }) + const [suggestions] = createResource(input, async (value) => { const cleaned = cleanPickerInput(value) const typed = cleaned.replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") + if (!cleaned || (root() && typed === current)) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) + if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } const base = location()?.directory + if (!base) return { query: value, items: directories.slice(0, 5) } const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base)) + if (query === undefined) return { query: value, items: directories.slice(0, 5) } + const files = await sdk.api.file .find({ location: location(), @@ -108,48 +121,64 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { }) .then((result) => result.data) .catch(() => []) + const results = [ ...directories, ...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })), ] + return { query: value, items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8), } }) - const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input())) + + const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions.latest, input())) async function load(path: string, generation: number, eager = false) { const key = path.replace(/\/+$/, "") setError(false) const absolute = absoluteTreePath(root(), key) const existing = listings.get(key) + if (existing && !eager) loads.promote(`${generation}:${key}`) + const request = existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) const current = location() + if (!current) return Promise.resolve(undefined) + return listPickerDirectory(sdk, current, absolute).catch(() => undefined) }) + listings.set(key, request) const nodes = await request + if (!activeTreeNavigation(generation, navigation)) return false + if (!nodes) { listings.delete(key) + if (!key) setError(true) + return false } + tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item }))) + if (!eager && advanceTreePreload(advanced, key)) { for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true) } + return true } async function navigate(path: string) { const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home())) + if (!value) return const token = ++navigation setLoading(true) @@ -163,6 +192,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { advanced.clear() tree?.resetPaths([]) const valid = await load("", token) + if (!activeTreeNavigation(token, navigation)) return setRootValid(valid) setLoading(false) @@ -171,9 +201,11 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { function complete() { const items = currentSuggestions() const match = items[activeSuggestion()] ?? items[0] + if (!match) return const value = displayPickerPath(match.absolute, input(), home()) setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value) + if (match.type === "file") { setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "") setSuggestionsOpen(false) @@ -184,8 +216,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) { if (suggestion.type === "directory") { void navigate(suggestion.absolute) + return } + setInput(displayPickerPath(suggestion.absolute, input(), home())) setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "") setSuggestionsOpen(false) @@ -199,23 +233,31 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { function activeSuggestionValue() { const items = currentSuggestions() + return items[activeSuggestion()] ?? items[0] } - const keyActions: Partial void>> = { - ArrowDown: () => moveSuggestion(1), - ArrowUp: () => moveSuggestion(-1), - Enter: () => { - const suggestion = activeSuggestionValue() - if (suggestion) chooseSuggestion(suggestion) - if (!suggestion) void navigate(input()) - }, - Tab: complete, - } + const keyActions = new Map([ + ["ArrowDown", () => moveSuggestion(1)], + ["ArrowUp", () => moveSuggestion(-1)], + [ + "Enter", + () => { + const suggestion = activeSuggestionValue() + + if (suggestion) chooseSuggestion(suggestion) + + if (!suggestion) void navigate(input()) + }, + ], + ["Tab", complete], + ]) function handleInputKey(event: KeyboardEvent) { - const action = keyActions[event.key] + const action = keyActions.get(event.key) + if (!action) return + if (event.key === "Tab" && event.shiftKey) return event.preventDefault() action() @@ -223,6 +265,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { function resolve() { const path = policy.result(root(), selected(), rootValid()) + if (!path) return props.onSelect(props.multiple ? [path] : path) dialog.close() @@ -230,10 +273,11 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { onMount(() => { const closeSuggestions = (event: PointerEvent) => { - if (pathArea?.contains(event.target as Node)) return + if (event.target instanceof Node && pathArea?.contains(event.target)) return setSuggestionsOpen(false) setActiveSuggestion(-1) } + document.addEventListener("pointerdown", closeSuggestions) onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions)) tree = new FileTree({ @@ -266,6 +310,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { setSelected(path ? (policy.selection(root(), path) ?? "") : "") }, }) + if (!container) return tree.render({ containerWrapper: container }) tree.getFileTreeContainer()?.classList.add("directory-picker-tree") @@ -273,6 +318,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { createEffect(() => { const path = start() + if (!path || !location() || root()) return void navigate(path) }) @@ -347,13 +393,16 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { const scroller = tree ?.getFileTreeContainer() ?.shadowRoot?.querySelector("[data-file-tree-virtualized-scroll]") + if (!scroller) return + const next = nextTreeScrollTop( scroller.scrollTop, event.deltaY, scroller.scrollHeight, scroller.clientHeight, ) + if (next === scroller.scrollTop) return event.preventDefault() scroller.scrollTop = next diff --git a/packages/app/src/workspaces/selection/picker.tsx b/packages/app/src/workspaces/selection/picker.tsx index 13c29917334d..6697c5687446 100644 --- a/packages/app/src/workspaces/selection/picker.tsx +++ b/packages/app/src/workspaces/selection/picker.tsx @@ -1,7 +1,7 @@ import { useDialog } from "@opencode/ui/context/dialog" import { ServerConnection } from "@/runtime/server/registry" import { usePlatform } from "@/runtime/platform/platform" -import { lazy } from "solid-js" +import { lazy, Suspense } from "solid-js" import type { LocationRef } from "@opencode/client/promise" import { directoryPickerKind } from "./policy" @@ -24,17 +24,29 @@ export function useDirectoryPicker() { return (input: DirectoryPickerInput) => { if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") { void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect) + return } let selected = false + const onSelect = (result: string | string[] | null) => { selected = result !== null input.onSelect(result) } + const cancel = () => { if (!selected) input.onSelect(null) } - dialog.show(() => , cancel) + + // Dialogs render under the caller's owner, so loading the lazy chunk would otherwise suspend the caller's boundary. + dialog.show( + () => ( + + + + ), + cancel, + ) } }