Skip to content

fix(app): align server sync with tui lifecycle - #41930

Open
Hona wants to merge 8 commits into
anomalyco:v2from
Hona:provider-ready-fix
Open

fix(app): align server sync with tui lifecycle#41930
Hona wants to merge 8 commits into
anomalyco:v2from
Hona:provider-ready-fix

Conversation

@Hona

@Hona Hona commented Aug 12, 2026

Copy link
Copy Markdown
Member

Server sync is a lifecycle, not a fetch

The visible failures looked unrelated:

  • A model dialog opened with no models.
  • The provider dialog showed only the custom OpenAI-compatible entry.
  • A missing Session escaped the Session route and reached the renderer error boundary.
  • A reconnect could leave model, tool, and location data stale.

They had one common cause. The app treated server state as independent HTTP results. The V2 server exposes one ordered lifecycle. The event-stream handshake, query ownership, catalog projection, reconnect, and route error boundary must agree about that lifecycle.

The contradiction that found the catalog bug

The live Electron app gave a useful contradiction.

GET /api/provider, GET /api/model, and GET /api/model/default all returned 200. The model control also rendered. That control only rendered after its direct provider queries had reached isSuccess. However, ModelsProvider and useProviders() still returned an empty catalog.

This excluded the server response and the normalizer. The loss occurred between the TanStack query observer and the child-store projection.

The relevant flow was:

server.connected
  -> enable directory query
  -> loadProvidersQuery()
  -> TanStack query result
  -> child.provider_ready / child.provider
  -> useProviders()
  -> ModelsProvider
  -> LocalProvider
  -> prompt control and dialogs

The prompt control and the model list did not use the same readiness source. The prompt control created two extra query observers. The model list read the child-store projection. One path could say “success” while the other path still said “empty.”

This PR makes the child projection the single source for both readiness and data.

The TanStack contract

The primitive choice matters here.

TanStack Solid Query documents createQuery options as reactive. Signal reads must occur inside the options accessor:

const query = createQuery(() => ({
  ...options(directory()),
  enabled: connection.status() === "connected",
}))

This is the correct primitive for server state that belongs to a mounted Solid owner. A connection-status change updates enabled, and TanStack starts or stops automatic query work from that state.

TanStack also documents two facts that are easy to combine incorrectly:

  1. A disabled query with no data has status: "pending", fetchStatus: "idle", and isLoading: false.
  2. data is a Solid resource. A read can activate Suspense when data is not available.

Sources:

The old readiness check used !isLoading. That is true for a disabled query with no data. An intermediate fix used data !== undefined. That reads the resource to answer a status question.

The final projection uses status fields for status:

const ready = query.isSuccess || query.isRefetchError

It reads query.data only after that guard. isRefetchError keeps valid cached data visible when a background refresh fails.

This matches the purpose of each primitive:

Need Primitive
Own reactive server state for a mounted directory createQuery
Share one key and one query function queryOptions
Gate a query on the event handshake reactive enabled
Decide if cached data is usable isSuccess or isRefetchError
Read the resolved payload data, after the status guard
Refresh after a server event queryClient.fetchQuery
Keep a derived structured view reactive Solid store getter or memo

Solid preserves getters on createStore. Reads inside a getter remain tracked. The previous global-provider fallback did not use a getter. It copied globalStore.provider once, usually while it was empty. The child store could never observe a later global catalog. The PR passes a live getter instead.

Source: Solid createStore getters

One query cache and one key

The old provider tree had two nested QueryClientProvider instances. Long-lived server contexts were created under one client. Route controls and dialogs could use the other client. A refetch could update a cache that ModelsProvider did not read.

The PR keeps one QueryClient at the app base. Server contexts, route controls, and dialogs now share it.

Windows exposed a second identity split. The live netlog showed both forms:

C:/Repos/sst/opencode
C:\Repos\sst\opencode

Those strings produced different query keys. A successful response under one key did not prove that the observer under the other key had data. Directory bootstrap now uses the same pathKey normalization as the child observer and prompt control.

After the fix, the cold-start netlog contains only the forward-slash key for that directory.

The event stream is the admission gate

V2 catalog and location data can change while plugins and integrations initialize. Starting queries before the event stream is connected creates a gap:

HTTP bootstrap starts
  -> server state changes
  -> event is emitted with no active consumer
  -> HTTP result becomes stale

ServerSDK now owns the stream lifecycle. It requires server.connected as the first event. Only then does connection.status() become connected.

Global and directory queries use that status in their reactive enabled option. Reconnect follows the same sequence:

stream closes
  -> status = reconnecting
  -> new stream starts
  -> first event must be server.connected
  -> status = connected
  -> pinned Sessions and active catalogs refresh

Catalog events invalidate the exact server and directory key. Location events refresh location, VCS, skills, web search, and shell data for the affected directory. Different directories remain independent.

queryClient.fetchQuery is intentional in this event path. This work is imperative. A server fact changed, so the cache must load a new snapshot. UI ownership still uses declarative createQuery.

“Available providers” is not “providers that can be connected”

The empty provider dialog had a second defect.

/api/provider returns providers that are available to the model catalog. It is not the provider-definition inventory. The connect dialog used that response as if it contained all integrations.

The correct source is /api/integration.

The new loadIntegrationsQuery() uses the same server scope and normalized directory key as the catalog query. useIntegrations() owns it with createQuery. The connect dialog and the unpaid-model dialog use that list. The selected integration still resolves its current methods through integration.get.

The live local server returned:

integration count: 185
featured integrations:
  anthropic
  google
  openai
  opencode
  opencode-go
  openrouter
  vercel

The model list continues to use /api/provider plus /api/model. This separation is important:

Integration list: what the user can connect
Provider/model catalog: what the user can run now

Integration and catalog events refresh both caches.

Session events use the same lifecycle

The stream adapter now carries current V2 events into the app projection. ServerSession applies current Session events, pending inputs, forms, messages, and transient state. Transient hydration records a revision before its HTTP load. A newer stream event changes that revision, so an older HTTP result cannot overwrite the event projection.

On reconnect, pinned Sessions refresh. Active Session status is reconciled in both directions. A Session that is absent from the active response becomes idle. A Session that is present becomes busy.

The event consumer also batches adjacent text, reasoning, tool-input, and compaction deltas. It keeps event order while it reduces render work.

Missing Sessions belong to the Session route

A target Session can fail before TargetSessionRouteContent mounts. For example, target-server providers can resolve a stale Session ID during provisioning.

The old Session error boundary was inside that provisioning subtree. A typed SessionNotFoundError could bypass it and reach the root renderer boundary.

The route now has two boundaries:

outer Session boundary
  -> target server provisioning
     -> inner Session boundary
        -> target directory and Session UI

The outer boundary catches provisioning failures. The inner boundary keeps the existing target-scoped recovery behavior. Both use the same typed Session-not-found predicate.

Local development identity

The desktop development script already set OPENCODE_CHANNEL=local. The desktop Vite config accepted local, but the shared app Vite plugin did not. It replaced the value with dev.

The shared plugin now accepts local. A cold bun dev:desktop start shows LOCAL, uses a 2.0.0-local-* server version, and keeps the local channel database.

Evidence from the real process

The investigation used the running Electron renderer and its real sidecar. It did not use mocked server state.

The cold-start renderer showed:

channel: LOCAL
model control: Big Pickle
control type: popover

The live model menu contained OpenCode, OpenAI, and Google groups. The real integration query returned 185 entries. The netlog showed normalized provider and model URLs. App type checking completed after the final upstream merge.

Earlier focused checks on this branch also covered the server SDK, Session projection, global sync reducers, catalog invalidation, and browser model-selection flow. No public Protocol or Server HttpApi changed in this PR. The generated client is only consumed through its existing integration API.

Result

The app now has one reproducible rule:

The event handshake admits server queries. TanStack query status admits payload reads. The child projection admits UI controls.

Each boundary has one owner, one key, and one reactive source. That removes the state in which HTTP says “success,” the control says “ready,” and the dialog still says “empty.”

@Hona
Hona requested a review from Brendonovich as a code owner August 12, 2026 02:52
Copilot AI lite review requested due to automatic review settings August 12, 2026 02:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aligns the app’s server-scoped data flow with the event-stream lifecycle by gating server queries on the initial server.connected handshake, refreshing/invalidation on reconnect, and extending V2 session projection/hydration so transient state can be safely rehydrated after reconnects.

Changes:

  • Gate server-scoped queries and bootstrapping on the event-stream handshake and add reconnect-aware refresh/invalidation hooks.
  • Introduce catalog/connection/location sync helpers to refresh connection-sensitive query data from events.
  • Improve V2 session projection to include transient pending inputs/forms and handle additional V2 event cases (including removals and non-initial instruction updates).

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/app/src/pages/session/composer/session-composer-controls.ts Gate provider queries on connection status and adjust provider-loading behavior in composer controls.
packages/app/src/context/server-sync/location.ts New event-driven location sync to refresh per-directory caches and project shell events.
packages/app/src/context/server-sync/location.test.ts Tests for location sync shell projection and refresh triggers.
packages/app/src/context/server-sync/connection.ts New connection sync to invalidate disconnected state and react to handshake completion.
packages/app/src/context/server-sync/connection.test.ts Tests for connection invalidation and handshake synchronization.
packages/app/src/context/server-sync/catalog.ts New catalog sync to invalidate/reload provider catalog per location and after reconnect.
packages/app/src/context/server-sync/catalog.test.ts Tests for catalog invalidation by directory and on connection.
packages/app/src/context/server-sync.tsx Wire handshake-gated queries, reconnect refresh, catalog/connection/location sync, and transient hydration.
packages/app/src/context/server-sync.test.ts Add coverage for active session status reconciliation after reconnect.
packages/app/src/context/server-session.ts Project additional V2 transient events (pending inputs/forms), add transient hydration guards, and tweak V2 message projection.
packages/app/src/context/server-session.test.ts Tests for pending/forms projection and transient hydration race protection.
packages/app/src/context/server-session-v2-reducer.ts Extend reducer to append admitted inputs, record removals, and project non-initial instruction updates.
packages/app/src/context/server-session-v2-reducer.test.ts Tests for instruction updates and updated pending-input folding behavior.
packages/app/src/context/server-sdk.tsx Require server.connected as stream handshake; add connection status tracking and reconnect loop with timeout/backoff.
packages/app/src/context/server-sdk.test.ts Tests for handshake requirement helper and related stream behaviors.
packages/app/src/context/global.tsx Remove per-server QueryClient creation from global server context return value.
packages/app/src/context/global-sync/event-reducer.ts Stop using server.connected as a global refresh trigger (delegated to connection sync).
packages/app/src/context/global-sync/event-reducer.test.ts Update tests to reflect server.connected refresh responsibility shift.
packages/app/src/context/global-sync/child-store.ts Gate per-directory queries on connection handshake and tighten provider readiness detection.
packages/app/src/context/global-sync/child-store.test.ts Tests for handshake gating and provider readiness when provider query is cancelled/unsuccessful.
packages/app/src/context/global-sync/bootstrap.ts Refactor bootstrap async sequencing without changing behavior (remove nested async IIFE).
packages/app/src/app.tsx Wrap target session route content in a session-scoped error boundary; remove QueryProvider wrapper usage in shell.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 56 to 60
loading:
(local.agent.visible() && agentsQuery.isLoading) ||
providersQuery.isLoading ||
globalProvidersQuery.isLoading,
!providersQuery.isSuccess ||
!globalProvidersQuery.isSuccess,
},
scope: serverSDK.scope,
queryClient,
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
info: (directory) => serverSDK.api.location.get({ location: { directory } }),
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants