Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/app/e2e/regression/session-location-sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
142 changes: 142 additions & 0 deletions packages/app/src/session/composer/location-missing.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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 attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
<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({})} disabled={move.isPending}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={worktrees.loading && otherWorktrees().length === 0}>
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
</Show>
<For each={otherWorktrees()}>
{(worktree) => (
<Menu.Item
title={worktree.directory}
onSelect={() => move.mutate({ 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>
)
}
52 changes: 40 additions & 12 deletions packages/app/src/session/composer/region.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -285,24 +286,51 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>

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 (
<SessionComposerRegion
controller={props.model.region}
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}
<Show
when={missing()}
keyed
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>
}
>
{(current) => (
<SessionLocationMissing
sessionID={current.sessionID}
projectID={current.projectID}
directory={current.directory}
/>
</div>
</div>
)}
</Show>
}
/>
)
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/session/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function ResolvedTargetSessionRoute() {
>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value}>
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/session/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,9 @@ function SessionScreenContent(props: {
</div>

<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
{(model) => (
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
)}
</Show>
</>
)
Expand Down
Loading
Loading