Compare commits

...
7 changed files with 391 additions and 33 deletions
+54 -11
View File
@@ -1,7 +1,18 @@
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client"
import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js"
import {
createContext,
createEffect,
createMemo,
createSignal,
onCleanup,
useContext,
type ParentProps,
} from "solid-js"
import { useClient } from "./client"
import { useData } from "./data"
import { useLog } from "./log"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
const context = createContext<{
readonly current: LocationGetOutput | undefined
@@ -9,16 +20,25 @@ const context = createContext<{
readonly ref: LocationRef | undefined
readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined
set: (location?: LocationRef) => void
retry: () => void
}>()
export function LocationProvider(props: ParentProps) {
const client = useClient()
const data = useData()
const toast = useToast()
const log = useLog()
const [ref, setRef] = createSignal<LocationRef>()
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
let generation = 0
const current = createMemo(() => data.location.info(ref()))
// A reconnect marks the connection ready before its buffered server.connected event.
// Invalidate old HTTP attempts at disconnect, not only when the next sync starts.
createEffect(() => {
if (client.connection.status() !== "connected") generation++
})
function sync(location?: LocationRef) {
if (!location) return
const attempt = ++generation
@@ -28,16 +48,38 @@ export function LocationProvider(props: ParentProps) {
? undefined
: location
setError(undefined)
void data.location.sync(target).catch((cause) => {
const current = ref()
if (
generation !== attempt ||
current?.directory !== location.directory ||
current.workspaceID !== location.workspaceID
)
return
setError({ location, cause })
})
const active = () =>
generation === attempt &&
ref()?.directory === location.directory &&
ref()?.workspaceID === location.workspaceID &&
client.connection.status() === "connected"
let resolved = false
void data.location
.syncInfo(target)
.then(() => {
// syncInfo is cached: the remaining sync loads catalogs for the resolved location.
resolved = true
return data.location.sync(target)
})
.catch((cause) => {
if (!active()) return
if (!resolved) {
setError({ location, cause })
return
}
log.error("Session data sync failed", { cause })
toast.show({
variant: "error",
title: "Session data sync failed",
message: `Some session data could not be loaded (${errorMessage(cause)}).`,
action: {
label: "Retry",
run: () => {
if (active()) sync(location)
},
},
})
})
}
function set(location?: LocationRef) {
@@ -60,6 +102,7 @@ export function LocationProvider(props: ParentProps) {
return error()
},
set,
retry: () => sync(ref()),
}}
>
{props.children}
@@ -12,7 +12,7 @@ const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const [message, setMessage] = createSignal("Choose another directory to continue")
const [message, setMessage] = createSignal("Retry or choose another directory")
const open = () =>
props.context.ui.dialog.show(() => (
<DialogMoveSession
@@ -50,15 +50,20 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Workerd Modal workspace driver
</text>
<text fg={theme.text.subdued}>build · GPT-5.6 Sol (high)</text>
<text fg={theme.text.subdued}>build · Demo Model</text>
<box height={1} />
<text fg={theme.text.default}>You</text>
<text fg={theme.text.subdued}>Test the mounted workspace and verify the deployment.</text>
<box height={1} />
<text fg={theme.text.default}>Build · GPT-5.6 Sol (high)</text>
<text fg={theme.text.default}>Build · Demo Model</text>
<text fg={theme.text.subdued}>The deployment is verified and the worktree is clean.</text>
<box flexGrow={1} />
<SessionLocationUnavailable directory={directory} onMove={open} />
<SessionLocationUnavailable
directory={directory}
message="Could not initialize this location"
onRetry={() => setMessage("Retried location sync")}
onMove={open}
/>
</box>
<StoryFooter
context={props.context}
+2 -6
View File
@@ -110,7 +110,7 @@ import { createSingleFlight } from "../../util/single-flight"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { generateThinkingSyntax } from "./thinking-syntax"
import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { SessionLocationError } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { useSessionTerminals } from "../../context/session-terminals"
@@ -1485,11 +1485,7 @@ export function Session(props: {
currentLocation.error?.location.workspaceID === session()!.location.workspaceID
}
>
<SessionLocationMissing
directory={session()!.location.directory}
projectID={session()!.projectID}
sessionID={route.sessionID}
/>
<SessionLocationError projectID={session()!.projectID} sessionID={route.sessionID} />
</Match>
<Match when={!disabled()}>
<Prompt
@@ -1,36 +1,55 @@
import { createMemo } from "solid-js"
import { createMemo, Show } from "solid-js"
import { useLocation } from "../../context/location"
import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { Locale } from "../../util/locale"
import { abbreviateHome } from "../../util/path-format"
import { SessionQuestion } from "./permission"
import { usePromptMove } from "../../component/prompt/move"
import { errorMessage } from "../../util/error"
export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) {
export function SessionLocationError(props: { projectID: string; sessionID: string }) {
const location = useLocation()
const move = usePromptMove({ projectID: () => props.projectID, sessionID: () => props.sessionID })
return <SessionLocationUnavailable directory={props.directory} onMove={move.open} />
return (
<Show when={location.error}>
{(error) => (
<SessionLocationUnavailable
directory={error().location.directory}
message={errorMessage(error().cause)}
onRetry={location.retry}
onMove={move.open}
/>
)}
</Show>
)
}
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
export function SessionLocationUnavailable(props: {
directory: string
message: string
onRetry: () => void
onMove: () => void
}) {
const paths = useTuiPaths()
const theme = useTheme("elevated")
const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
return (
<SessionQuestion
id="session.location-missing"
group="Session recovery"
choicesLabel="Recovery actions"
id="session.location-sync-error"
group="Session sync"
choicesLabel="Sync actions"
instance={props.directory}
title="Session location unavailable"
title="Could not load session location"
body={
<box paddingLeft={1} gap={1}>
<text fg={theme.text.subdued}>{directory()}</text>
<text fg={theme.text.default}>Choose another directory to continue this session.</text>
<text fg={theme.text.default}>{props.message}</text>
</box>
}
options={{ move: "Choose directory" }}
onSelect={props.onMove}
options={{ retry: "Retry", move: "Choose directory" }}
onSelect={(option) => (option === "retry" ? props.onRetry() : props.onMove())}
/>
)
}
+154
View File
@@ -0,0 +1,154 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createEffect } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { LocationProvider, useLocation } from "../../src/context/location"
import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
import { useToast } from "../../src/ui/toast"
test.each([
{ endpoint: "location", reconnect: false },
{ endpoint: "agent", reconnect: false },
{ endpoint: "agent", reconnect: true },
])("a late failure cannot replace the new sync's state (%o)", async ({ endpoint, reconnect }) => {
// Keep the held lookup separate from the client's launch-directory preload.
const source = `${directory}/old`
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const events = createEventStream()
let requests = 0
let connections = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/event") connections++
const target = url.searchParams.get("location[directory]") ?? directory
if (target === source && url.pathname === `/api/${endpoint}` && ++requests === 1) {
requested.resolve()
return response.promise
}
const location = { directory: target, project: { id: "project", directory: target, canonical: target } }
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent") return json({ location, data: [] })
return undefined
}, events)
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let toast!: ReturnType<typeof useToast>
function Probe() {
const client = useClient()
location = useLocation()
data = useData()
toast = useToast()
location.set({ directory: source })
createEffect(() => {
// Connection status changes before the buffered server.connected event is published.
// Deliver the old HTTP failure in that gap, not after an arbitrary timer.
if (client.connection.status() === "connected" && reconnect && connections > 1)
response.resolve(json({ message: "Old location sync failed" }, { status: 500 }))
})
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={directory}>
<LocationProvider>
<Probe />
</LocationProvider>
</DataProvider>
</ClientProvider>
</ConfigProvider>
</TestTuiContexts>
))
app.renderer.start()
try {
await requested.promise
const target = reconnect ? source : "/other"
if (reconnect) {
events.disconnect()
await app.waitFor(() => connections > 1, { maxPasses: 120 })
}
if (!reconnect) location.set({ directory: target })
if (!reconnect) response.resolve(json({ message: "Old location sync failed" }, { status: 500 }))
await app.waitFor(() => data.location.agent.list({ directory: target }) !== undefined)
await app.waitForVisualIdle()
expect(location.ref).toEqual({ directory: target })
expect(location.current?.directory).toBe(target)
expect(location.error).toBeUndefined()
expect(toast.currentToast).toBeNull()
} finally {
response.resolve(json({ message: "Old location sync failed" }, { status: 500 }))
app.renderer.destroy()
}
})
test("catalog failures preserve resolved info and an old Retry cannot sync a different location", async () => {
const requests: string[] = []
const causes: unknown[] = []
const failure = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
const target = url.searchParams.get("location[directory]") ?? directory
requests.push(`${target}:${url.pathname}`)
const location = { directory: target, project: { id: "project", directory: target, canonical: target } }
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent" && target === directory) return failure.promise
if (url.pathname === "/api/agent") return json({ location, data: [] })
return undefined
}, createEventStream())
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let toast!: ReturnType<typeof useToast>
function Probe() {
location = useLocation()
data = useData()
toast = useToast()
location.set({ directory })
return <box />
}
const app = await testRender(() => (
<TestTuiContexts
log={(_, message, tags) => {
if (message === "Session data sync failed") causes.push(tags.cause)
}}
>
<ConfigProvider config={createTuiResolvedConfig()}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={directory}>
<LocationProvider>
<Probe />
</LocationProvider>
</DataProvider>
</ClientProvider>
</ConfigProvider>
</TestTuiContexts>
))
app.renderer.start()
try {
await app.waitFor(() => requests.includes(`${directory}:/api/agent`))
const original = data.location.agent.sync({ directory }).catch((cause: unknown) => cause)
failure.resolve(json({ message: "Agent catalog temporarily unavailable" }, { status: 500 }))
await app.waitFor(() => toast.currentToast !== null)
expect(causes).toEqual([await original])
expect(causes[0]).toBe(await original)
expect(location.current?.directory).toBe(directory)
expect(location.error).toBeUndefined()
const retry = toast.currentToast?.action?.run
expect(retry).toBeDefined()
location.set({ directory: "/other" })
await app.waitFor(() => data.location.agent.list({ directory: "/other" }) !== undefined)
const before = requests.length
retry?.()
await app.waitForVisualIdle()
expect(requests).toHaveLength(before)
expect(location.ref).toEqual({ directory: "/other" })
expect(location.error).toBeUndefined()
} finally {
failure.resolve(json({ message: "Agent catalog temporarily unavailable" }, { status: 500 }))
app.renderer.destroy()
}
})
@@ -8,6 +8,7 @@ import {
import type { ParentProps } from "solid-js"
import { LogProvider, type LogSink } from "../../src/context/log"
import { ClipboardProvider, type ClipboardService } from "../../src/context/clipboard"
import { ToastProvider } from "../../src/ui/toast"
const clipboard: ClipboardService = {
async read() {
@@ -38,7 +39,9 @@ export function TestTuiContexts(
>
<TuiTerminalEnvironmentProvider value={{ platform: "linux" }}>
<TuiStartupProvider value={{ skipInitialLoading: false }}>
<ClipboardProvider value={props.clipboard ?? clipboard}>{props.children}</ClipboardProvider>
<ClipboardProvider value={props.clipboard ?? clipboard}>
<ToastProvider>{props.children}</ToastProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
@@ -0,0 +1,138 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([
{ width: 70, endpoint: "agent", initial: true },
{ width: 120, endpoint: "agent", initial: false },
{ width: 100, endpoint: "model", initial: false },
{ width: 100, endpoint: "mcp", initial: false },
{ width: 100, endpoint: "location", initial: true },
{ width: 100, endpoint: "location", initial: false },
])("session sync offers truthful recovery (%o)", async ({ width, endpoint, initial }) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const sessionID = `ses_location_sync_${endpoint}_${width}`
const location = { directory, project: { id: "project", directory, canonical: directory } }
let agents = 0
let failures = 0
let healthy = !initial
let recovered = 0
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "project",
title: "Location sync fixture",
model: { providerID: "demo", id: "model" },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/permission`)
return json({ data: [] })
if (url.pathname === "/api/worktree/project") return json([{ directory: "/other" }])
if (url.pathname === "/api/worktree/project/refresh") return new Response(null, { status: 204 })
if (url.pathname === `/api/${endpoint}` && !healthy) {
if (endpoint === "agent") agents++
failures++
return json({ message: "Service temporarily unavailable" }, { status: 500 })
}
if (url.pathname === `/api/${endpoint}` && failures > 0) recovered++
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent") {
agents++
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
}
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
return undefined
}, events)
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
const title = endpoint === "location" ? "Could not load session location" : "Session data sync failed"
if (!initial) {
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("Keep this draft")
await setup.waitForFrame((frame) => frame.includes("Keep this draft"))
healthy = false
events.disconnect()
}
const editor = setup.renderer.currentFocusedEditor
await setup.waitFor(() => failures > 0, { maxPasses: 120 })
// Also settle on the old panel so this remains an assertion failure on the base revision.
await setup.waitForFrame((frame) => frame.includes("Session location unavailable") || frame.includes(title))
await setup.waitForVisualIdle()
const frame = setup.captureCharFrame()
expect(frame).not.toContain("Choose another directory to continue this session.")
expect(frame).toContain(title)
// Undeclared HTTP 500 bodies are deliberately not decoded by the generated client.
expect(frame).toContain("UnexpectedStatus")
expect(frame).toContain("Retry")
if (endpoint !== "location") {
expect(agents).toBeGreaterThan(0)
expect(frame).not.toContain("Choose directory")
if (!initial) {
expect(setup.renderer.currentFocusedEditor).toBe(editor)
expect(editor?.plainText).toBe("Keep this draft")
}
}
if (endpoint === "location") {
expect(frame).toContain("Choose directory")
const lines = frame.split("\n")
const row = lines.findIndex((line) => line.includes("Choose directory"))
await setup.mockMouse.click(lines[row]!.indexOf("Choose directory"), row)
await setup.waitForFrame((frame) => frame.includes("Move session") && frame.includes("/other"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Move session") && frame.includes(title))
}
const retry = async () => {
const lines = setup.captureCharFrame().split("\n")
const row = lines.findIndex((line) => line.includes("Retry"))
expect(row).toBeGreaterThanOrEqual(0)
await setup.mockMouse.click(lines[row]!.indexOf("Retry"), row)
}
const before = failures
await retry()
await setup.waitFor(() => failures > before)
await setup.waitForFrame((frame) => frame.includes(title))
healthy = true
await retry()
await setup.waitFor(() => recovered > 0)
await setup.waitForFrame((frame) => frame.includes("Demo Model") && !frame.includes(title))
expect(agents).toBeGreaterThan(0)
expect(setup.captureCharFrame()).not.toContain("Choose directory")
if (!initial && endpoint !== "location") {
expect(setup.renderer.currentFocusedEditor).toBe(editor)
expect(editor?.plainText).toBe("Keep this draft")
await setup.mockInput.typeText(" after retry")
await setup.waitForFrame((frame) => frame.includes("Keep this draft after retry"))
}
} finally {
setup.renderer.destroy()
await task.finally(() => server.stop(true))
}
})