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")}
+
+
+
+
+
+
+
+
+
+
+ )
+}
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,
+ )
}
}