Compare commits

...
Author SHA1 Message Date
Brendonovich 1c52f10f1c fix(app): recover sessions with missing directories 2026-08-25 09:43:24 +00:00
5 changed files with 163 additions and 7 deletions
+4
View File
@@ -1145,6 +1145,10 @@ export const dict = {
"session.delete.title": "Delete session",
"session.delete.confirm": 'Delete session "{{name}}"?',
"session.delete.button": "Delete session",
"session.locationUnavailable.title": "Working directory unavailable",
"session.locationUnavailable.description": "This session is read-only until you move it to another directory.",
"session.locationUnavailable.action": "Move session",
"session.locationUnavailable.pickerTitle": "Choose a new working directory",
"workspace.new": "New workspace",
"common.viewAll": "View all",
@@ -0,0 +1,54 @@
import { expect, test } from "bun:test"
import { moveSessionLocation } from "./location-recovery"
test("moves an unavailable session to the selected directory", async () => {
const moving: boolean[] = []
const moved: string[] = []
const result = await moveSessionLocation({
selection: ["/repo/recovered"],
moving: false,
setMoving: (value) => moving.push(value),
move: async (directory) => moved.push(directory),
failed: () => undefined,
})
expect(result).toBe(true)
expect(moved).toEqual(["/repo/recovered"])
expect(moving).toEqual([true, false])
})
test("keeps the recovery action available after a failed move", async () => {
const moving: boolean[] = []
const errors: unknown[] = []
const error = new Error("unavailable")
const result = await moveSessionLocation({
selection: "/repo/missing",
moving: false,
setMoving: (value) => moving.push(value),
move: async () => {
throw error
},
failed: (cause) => errors.push(cause),
})
expect(result).toBe(false)
expect(errors).toEqual([error])
expect(moving).toEqual([true, false])
})
test("ignores cancelled and duplicate recovery attempts", async () => {
let moves = 0
const input = {
setMoving: () => undefined,
move: async () => {
moves++
},
failed: () => undefined,
}
expect(await moveSessionLocation({ ...input, selection: null, moving: false })).toBe(false)
expect(await moveSessionLocation({ ...input, selection: "/repo/next", moving: true })).toBe(false)
expect(moves).toBe(0)
})
@@ -0,0 +1,20 @@
export async function moveSessionLocation(input: {
selection: string | string[] | null
moving: boolean
setMoving: (moving: boolean) => void
move: (directory: string) => Promise<unknown>
failed: (error: unknown) => void
}) {
const directory = Array.isArray(input.selection) ? input.selection[0] : input.selection
if (!directory || input.moving) return false
input.setMoving(true)
return input
.move(directory)
.then(() => true)
.catch((error) => {
input.failed(error)
return false
})
.finally(() => input.setMoving(false))
}
@@ -0,0 +1,69 @@
import { Button } from "@opencode-ai/ui/button"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { showToast } from "@/shell/notifications/toast"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { moveSessionLocation } from "./location-recovery"
export function SessionLocationUnavailable(props: { sessionID: string }) {
const language = useLanguage()
const serverSDK = useServerSDK()
const pickDirectory = useDirectoryPicker()
const [store, setStore] = createStore({ moving: false })
const chooseDirectory = () => {
if (store.moving) return
pickDirectory({
server: serverSDK.server,
title: language.t("session.locationUnavailable.pickerTitle"),
onSelect: (result) => {
void moveSessionLocation({
selection: result,
moving: store.moving,
setMoving: (moving) => setStore("moving", moving),
move: (directory) => serverSDK.api.session.move({ sessionID: props.sessionID, directory }),
failed: (error) =>
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
}),
})
},
})
}
return (
<SessionLocationUnavailableView
title={language.t("session.locationUnavailable.title")}
description={language.t("session.locationUnavailable.description")}
action={language.t("session.locationUnavailable.action")}
moving={store.moving}
onMove={chooseDirectory}
/>
)
}
export function SessionLocationUnavailableView(props: {
title: string
description: string
action: string
moving: boolean
onMove: () => void
}) {
return (
<div
data-component="session-location-unavailable"
class="flex w-full items-center gap-3 rounded-[12px] border border-border-weak-base bg-background-base p-3"
>
<div class="min-w-0 flex-1">
<div class="text-14-medium text-text-strong">{props.title}</div>
<div class="text-13-regular text-text-weak">{props.description}</div>
</div>
<Button type="button" variant="outline" icon="folder" disabled={props.moving} onClick={props.onMove}>
{props.action}
</Button>
</div>
)
}
+16 -7
View File
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount } from "solid-js"
import { createEffect, on, onMount, Show } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
@@ -32,6 +32,7 @@ import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
import { SessionLocationUnavailable } from "./location-unavailable"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -220,6 +221,7 @@ export function ActiveSessionComposerRegion(props: {
onResponseSubmit: () => void
}) {
const settings = useSettings()
const location = useWorkspaceLocation()
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
@@ -248,12 +250,19 @@ export function ActiveSessionComposerRegion(props: {
<SessionComposerRegion
controller={region}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
</div>
</div>
<Show
when={location().error && !location().current}
fallback={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
</div>
</div>
}
>
<SessionLocationUnavailable sessionID={requireSessionID(props.session)} />
</Show>
}
/>
)