Skip to content
Open
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
65 changes: 54 additions & 11 deletions packages/tui/src/context/location.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,44 @@
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
// The target location as set, available before the server-synced info in `current` arrives.
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
Expand All @@ -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) {
Expand All @@ -60,6 +102,7 @@ export function LocationProvider(props: ParentProps) {
return error()
},
set,
retry: () => sync(ref()),
}}
>
{props.children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 2 additions & 6 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
41 changes: 30 additions & 11 deletions packages/tui/src/routes/session/location-missing.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 154 additions & 0 deletions packages/tui/test/context/location.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
}
})
5 changes: 4 additions & 1 deletion packages/tui/test/fixture/tui-environment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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>
Expand Down
Loading
Loading