refactor(tui): simplify client data state

This commit is contained in:
Dax Raad 2026-07-08 10:37:29 -04:00
parent c6156f171c
commit 8b634e4a58
4 changed files with 65 additions and 230 deletions

View file

@ -1146,11 +1146,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
return render({ params: route.data.data })
})
// Suppress the full-screen reconnecting overlay for transient disconnects (initial startup, host
// reload, sub-second event-stream blips). After the first successful connect, show it only once the
// connection has been lost for a full second; before the first connect give a longer grace period so
// startup never flashes it, but a server that dies before ever connecting still surfaces instead of
// leaving a silent empty app. Hide it immediately the moment status leaves "connecting".
// Suppress the full-screen overlay for transient startup and event-stream retry states.
// Initial connection gets a longer grace period; retries surface more quickly.
const [showReconnecting, setShowReconnecting] = createSignal(false)
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
@ -1158,7 +1155,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
clearTimeout(reconnectTimer)
reconnectTimer = undefined
}
if (sdk.connection.status() !== "connecting") {
const status = sdk.connection.status()
if (status === "connected") {
setShowReconnecting(false)
return
}
@ -1167,7 +1165,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
reconnectTimer = undefined
setShowReconnecting(true)
},
sdk.connection.connectedOnce() ? 1000 : 5000,
status === "reconnecting" ? 1000 : 5000,
).unref()
})
onCleanup(() => {

View file

@ -1,3 +1,8 @@
// Client data layer: apply server events and cache API reads into a Solid store.
// Prefer straightforward projection. Do not add generation counters, stale-response
// merges, live/history overlays, or other race machinery here—last write wins.
// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns.
import type {
AgentInfo,
CommandInfo,
@ -106,36 +111,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
const sessionRefreshGeneration = new Map<string, number>()
const sessionRefreshApplied = new Map<string, number>()
const sessionUsage = new Map<string, { generation: number; cost: number; tokens: SessionInfo["tokens"] }>()
let connectionGeneration = 0
let statusChanges: Set<string> | undefined
let bootstrapping: Promise<void> | undefined
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
statusChanges?.add(sessionID)
setStore("session", "status", sessionID, status)
}
function nextSessionRefresh(sessionID: string) {
const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1
sessionRefreshGeneration.set(sessionID, generation)
return generation
}
function applySessionRefresh(sessionID: string, generation: number) {
if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false
sessionRefreshApplied.set(sessionID, generation)
return true
}
function updateSessionUsage(sessionID: string, cost: number, tokens: SessionInfo["tokens"]) {
sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens })
if (!store.session.info[sessionID]) return
setStore("session", "info", sessionID, { cost, tokens })
}
const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore(
@ -237,8 +218,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
function removeSession(sessionID: string) {
sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID))
sessionUsage.delete(sessionID)
messageIndex.delete(sessionID)
setStore(
"session",
@ -267,7 +246,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
removeSession(event.data.sessionID)
break
case "session.usage.updated":
updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens)
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, {
cost: event.data.cost,
tokens: event.data.tokens,
})
break
case "catalog.updated":
void Promise.all([
@ -807,20 +790,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const result = {
on: sdk.event.on,
listen: sdk.event.listen,
connection: {
status() {
return sdk.connection.status()
},
attempt() {
return sdk.connection.attempt()
},
error() {
return sdk.connection.error()
},
connectedOnce() {
return sdk.connection.connectedOnce()
},
},
session: {
list() {
return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated)
@ -846,17 +815,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
async refresh(sessionID: string) {
const generation = nextSessionRefresh(sessionID)
const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0
const info = mutable(await sdk.api.session.get({ sessionID }))
if (!applySessionRefresh(sessionID, generation)) return
const usage = sessionUsage.get(sessionID)
setStore(
"session",
"info",
sessionID,
usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info,
)
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
registerSession(sessionID)
},
message: {
@ -872,21 +831,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return position === undefined ? undefined : messages?.[position]
},
async refresh(sessionID: string) {
const live = [...(store.session.message[sessionID] ?? [])]
setStore("session", "message", sessionID, [])
messageIndex.set(sessionID, new Map())
const loaded = mutable(
const messages = mutable(
(await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data,
).toReversed()
const loadedIDs = new Set(loaded.map((message) => message.id))
const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [
...loaded.map((message) => {
if (message.type === "user") return message
return liveByID.get(message.id) ?? message
}),
...live.filter((message) => !loadedIDs.has(message.id)),
].toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)
},
@ -1031,8 +980,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
const generation = new Map(sessionRefreshApplied)
const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation]))
bootstrapping = Promise.allSettled([
sdk.api.session
.list({
@ -1046,15 +993,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
"session",
"info",
produce((draft) => {
for (const session of response.data) {
if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue
const usage = sessionUsage.get(session.id)
draft[session.id] = mutable(
usage && usage.generation !== (usageGeneration.get(session.id) ?? 0)
? { ...session, cost: usage.cost, tokens: usage.tokens }
: session,
)
}
for (const session of response.data) draft[session.id] = mutable(session)
}),
)
for (const session of response.data) registerSession(session.id)
@ -1101,23 +1040,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
function refreshActive() {
const generation = ++connectionGeneration
const changed = new Set<string>()
statusChanges = changed
void sdk.api.session
.active()
.then((active) => {
if (generation !== connectionGeneration) return
const status: Record<string, DataSessionStatus> = Object.fromEntries(
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
setStore(
"session",
"status",
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
)
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
setStore("session", "status", reconcile(status))
})
.catch(() => undefined)
.finally(() => {
if (statusChanges === changed) statusChanges = undefined
})
}
onCleanup(

View file

@ -5,7 +5,7 @@ import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
export type SDKConnectionStatus = "connected" | "connecting"
export type SDKConnectionStatus = "connected" | "connecting" | "reconnecting"
type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
const connectTimeout = 2_000
@ -27,11 +27,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
status: SDKConnectionStatus
attempt: number
error?: string
connectedOnce: boolean
}>({
status: "connecting",
attempt: 0,
connectedOnce: false,
})
let stream: AbortController | undefined
@ -70,7 +68,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
clearTimeout(timeout)
attempt = 0
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined, connectedOnce: true })
setConnection({ status: "connected", attempt: 0, error: undefined })
connected()
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
@ -98,7 +96,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}
}
setConnection({
status: "connecting",
status: "reconnecting",
attempt,
error: error instanceof Error ? error.message : String(error),
})
@ -136,9 +134,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
error() {
return connection.error
},
connectedOnce() {
return connection.connectedOnce
},
},
reload: props.reload,
}

View file

@ -6,7 +6,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { EventV2 } from "@opencode-ai/core/event"
import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { SDKProvider, useSDK } from "../../../src/context/sdk"
import { DataProvider, useData } from "../../../src/context/data"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
@ -114,24 +114,22 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("applies absolute usage events without losing full session updates", async () => {
test("applies absolute usage events to session info", async () => {
const events = createEventStream()
const sessionID = "ses_usage_refresh"
let resolveSessions!: (response: Response) => void
const resolveSession: Array<(response: Response) => void> = []
let sessionsRequested = false
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
sessionsRequested = true
return new Promise<Response>((resolve) => {
resolveSessions = resolve
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Usage",
location: { directory },
},
})
}
if (url.pathname === `/api/session/${sessionID}`) {
return new Promise<Response>((resolve) => {
resolveSession.push(resolve)
})
}
}, events)
let data!: ReturnType<typeof useData>
@ -153,7 +151,7 @@ test("applies absolute usage events without losing full session updates", async
))
try {
await wait(() => sessionsRequested)
await data.session.refresh(sessionID)
emitEvent(events, {
id: "evt_usage_2",
created: 2,
@ -164,38 +162,6 @@ test("applies absolute usage events without losing full session updates", async
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
const initialRefresh = data.session.refresh(sessionID)
await wait(() => resolveSession.length === 1)
resolveSessions(
json({
data: [
{
id: sessionID,
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Stale usage",
location: { directory },
},
],
cursor: {},
}),
)
resolveSession[0](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0.5,
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Current usage",
location: { directory },
},
}),
)
await initialRefresh
await wait(() => data.session.get(sessionID)?.cost === 0.5)
expect(data.session.get(sessionID)?.tokens).toEqual({
input: 5,
@ -204,7 +170,6 @@ test("applies absolute usage events without losing full session updates", async
cache: { read: 1, write: 1 },
})
const fullRefresh = data.session.refresh(sessionID)
emitEvent(events, {
id: "evt_usage_3",
created: 3,
@ -216,57 +181,8 @@ test("applies absolute usage events without losing full session updates", async
},
})
await wait(() => data.session.get(sessionID)?.cost === 1)
resolveSession[1](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0.75,
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Older usage",
location: { directory },
},
}),
)
await fullRefresh
await Bun.sleep(20)
expect(data.session.get(sessionID)?.cost).toBe(1)
expect(data.session.get(sessionID)?.title).toBe("Older usage")
expect(data.session.get(sessionID)?.title).toBe("Usage")
emitEvent(events, {
id: "evt_usage_6",
created: 6,
type: "session.usage.updated",
data: {
sessionID,
cost: 1.25,
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
emitEvent(events, {
id: "evt_usage_7",
created: 7,
type: "session.usage.updated",
data: {
sessionID,
cost: 1.25,
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
await wait(() => data.session.get(sessionID)?.cost === 1.25)
expect(data.session.get(sessionID)?.title).toBe("Older usage")
emitEvent(events, {
id: "evt_usage_8",
created: 8,
type: "session.usage.updated",
data: {
sessionID,
cost: 1.5,
tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
emitEvent(events, {
id: "evt_usage_deleted",
created: 9,
@ -274,8 +190,7 @@ test("applies absolute usage events without losing full session updates", async
durable: durable(sessionID, 9, 2),
data: { sessionID },
})
await Bun.sleep(20)
expect(data.session.get(sessionID)).toBeUndefined()
await wait(() => data.session.get(sessionID) === undefined)
} finally {
app.renderer.destroy()
}
@ -579,9 +494,11 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
})
}, events)
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
function Probe() {
data = useData()
sdk = useSDK()
return <box />
}
@ -600,32 +517,24 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
try {
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
await wait(() => data.session.status("session-stale") === "running")
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
expect(sdk.connection.status()).toBe("connected")
expect(sdk.connection.attempt()).toBe(0)
events.disconnect()
await wait(() => data.connection.status() === "connecting")
expect(data.connection.attempt()).toBe(1)
expect(data.connection.error()).toBe("Event stream disconnected")
await wait(() => sdk.connection.status() === "reconnecting")
expect(sdk.connection.attempt()).toBe(1)
expect(sdk.connection.error()).toBe("Event stream disconnected")
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
emitEvent(events, {
id: "evt_execution_started_after_reconnect",
created: 1,
type: "session.execution.started",
durable: durable("session-new"),
data: { sessionID: "session-new" },
})
await wait(() => data.session.status("session-new") === "running")
resolveActive(json({ data: {} }))
await wait(() => requests.active === 2 && sdk.connection.status() === "connected", 4000)
resolveActive(json({ data: { "session-new": { type: "running" } } }))
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle")
expect(data.session.status("session-new")).toBe("running")
expect(requests.event).toBe(2)
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
expect(data.connection.error()).toBeUndefined()
expect(sdk.connection.status()).toBe("connected")
expect(sdk.connection.attempt()).toBe(0)
expect(sdk.connection.error()).toBeUndefined()
} finally {
app.renderer.destroy()
}
@ -766,7 +675,7 @@ test("removes committed revert messages from local state", async () => {
}
})
test("connectedOnce is false until first connect and persists across disconnect", async () => {
test("distinguishes initial connection from reconnection", async () => {
const encoder = new TextEncoder()
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
const eventResponse = () =>
@ -792,10 +701,10 @@ test("connectedOnce is false until first connect and persists across disconnect"
const calls = createFetch((url) => {
if (url.pathname === "/api/event") return eventResponse()
})
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
function Probe() {
data = useData()
sdk = useSDK()
return <box />
}
@ -813,16 +722,13 @@ test("connectedOnce is false until first connect and persists across disconnect"
try {
await wait(() => stream !== undefined)
expect(data.connection.status()).toBe("connecting")
expect(data.connection.connectedOnce()).toBe(false)
expect(sdk.connection.status()).toBe("connecting")
connect()
await wait(() => data.connection.status() === "connected")
expect(data.connection.connectedOnce()).toBe(true)
await wait(() => sdk.connection.status() === "connected")
disconnect()
await wait(() => data.connection.status() === "connecting")
expect(data.connection.connectedOnce()).toBe(true)
await wait(() => sdk.connection.status() === "reconnecting")
} finally {
app.renderer.destroy()
}
@ -1462,9 +1368,11 @@ test("adds and dismisses permission requests from live events", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
function Probe() {
data = useData()
sdk = useSDK()
return <box />
}
@ -1481,7 +1389,7 @@ test("adds and dismisses permission requests from live events", async () => {
))
try {
await wait(() => data.connection.status() === "connected")
await wait(() => sdk.connection.status() === "connected")
emitEvent(events, {
id: "evt_permission_asked_1",
created: 0,
@ -1580,9 +1488,11 @@ test("adds, dismisses, and refreshes form requests", async () => {
return json({ data: [{ id: "frm_remote", sessionID: "ses_1", mode: "form", fields: [] }] })
}, events)
let data!: ReturnType<typeof useData>
let sdk!: ReturnType<typeof useSDK>
function Probe() {
data = useData()
sdk = useSDK()
return <box />
}
@ -1599,7 +1509,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
))
try {
await wait(() => data.connection.status() === "connected")
await wait(() => sdk.connection.status() === "connected")
emitEvent(events, {
id: "evt_form_created_1",
created: 0,