refactor(client): simplify local service lifecycle

This commit is contained in:
Dax Raad 2026-07-15 13:16:49 -04:00
commit 75bc611ef1
51 changed files with 635 additions and 534 deletions

View file

@ -1,7 +1,7 @@
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner"
import { Deferred, Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode } from "@opencode-ai/client"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
@ -138,9 +138,9 @@ const appBindingCommands = [
export type TuiInput = {
server: {
endpoint: Service.Endpoint
endpoint: Endpoint
service?: {
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
reconnect: (signal: AbortSignal) => Promise<Endpoint>
restart: () => Promise<void>
}
}
@ -189,8 +189,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const managed = input.server.service
const service = managed
? {
reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
const endpoint = await managed.reconnect(onStatus, signal)
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next) }
},
@ -1114,7 +1114,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
<Reconnecting status={client.connection.service()} />
<Reconnecting />
</Show>
<Toast />
</box>

View file

@ -1,11 +1,8 @@
import type { Service } from "@opencode-ai/client/effect"
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting(props: { status?: Service.Status }) {
export function Reconnecting() {
const theme = useTheme().theme
const copy = () => reconnectingCopy(props.status)
return (
<box
@ -20,46 +17,8 @@ export function Reconnecting(props: { status?: Service.Status }) {
justifyContent="center"
>
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<Show when={!copy().loading} fallback={<Spinner color={theme.textMuted}>{copy().message}</Spinner>}>
<text fg={theme.error}>{copy().message}</text>
<Show when={copy().detail}>
{(detail) => (
<text fg={theme.textMuted} wrapMode="word">
{detail()}
</text>
)}
</Show>
<Show when={copy().action}>
{(action) => (
<text fg={theme.text} wrapMode="word">
{action()}
</text>
)}
</Show>
</Show>
<Spinner color={theme.textMuted}>Waiting for background service...</Spinner>
</box>
</box>
)
}
export function reconnectingCopy(status?: Service.Status) {
if (status?.type === "starting")
return {
loading: true,
message: status.version ? `Starting OpenCode ${status.version}...` : "Starting background service...",
}
if (status?.type === "stopping")
return {
loading: true,
message: status.targetVersion ? `Updating to ${status.targetVersion}...` : "Restarting background service...",
}
if (status?.type === "failed")
return { loading: false, message: "Background service failed", detail: status.message, action: status.action }
if (status?.type === "unresponsive")
return {
loading: false,
message: "Background service is not responding",
action: "Run `opencode service restart` to recover it.",
}
return { loading: true, message: "Waiting for background service..." }
}

View file

@ -1,7 +1,6 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createSignal, onCleanup, onMount } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper"
@ -19,7 +18,7 @@ export type ClientConnectionEvent = {
}
type ManagedService = {
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
@ -43,7 +42,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
status: "connecting",
attempt: 0,
})
const [service, setService] = createSignal<Service.Status>()
let stream: AbortController | undefined
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
@ -81,7 +79,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
setService(undefined)
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
@ -116,7 +113,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
// moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address.
if (props.service) {
const next = await props.service.reconnect(setService, controller.signal).catch((error) => {
const next = await props.service.reconnect(controller.signal).catch((error) => {
if (!controller.signal.aborted)
log.info("server resolution failed", {
attempt,
@ -159,9 +156,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
error() {
return connection.error
},
service() {
return service()
},
internal: {
history() {
return history.slice()