From 3d529428f52fd922a1b829657ae6229203754bce Mon Sep 17 00:00:00 2001 From: ofrades Date: Wed, 26 Aug 2026 17:42:43 +0100 Subject: [PATCH] fix(tui): guard server-synced store fields against null responses A null/undefined data field from any bootstrap endpoint (providers, agents, config, console state, MCP status) crashed the TUI with "Object.entries requires that input parameter not be null or undefined" inside a Solid.js reactive computation. Non-null assertions in the sync bootstrap propagated nulls straight into the store. - replace bootstrap `x.data!` assertions with fallback shapes so the store never receives null for object/array-typed fields - guard every Object.entries/keys/values consumer of sync.data.mcp, lsp and theme.theme with ?? fallbacks (defense-in-depth, matching the pattern of PRs #22105/#22206) Regression family: #21014, #20388, #5151 --- packages/tui/src/component/dialog-status.tsx | 6 ++--- packages/tui/src/context/local.tsx | 4 +-- packages/tui/src/context/sync.tsx | 26 +++++++++++--------- packages/tui/src/plugin/adapters.tsx | 2 +- packages/tui/src/routes/session/footer.tsx | 6 ++--- packages/tui/src/theme/index.ts | 2 +- 6 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx index 6c8fabdbb3a3..676c330e3dcf 100644 --- a/packages/tui/src/component/dialog-status.tsx +++ b/packages/tui/src/component/dialog-status.tsx @@ -50,10 +50,10 @@ export function DialogStatus() { esc - 0} fallback={No MCP Servers}> + 0} fallback={No MCP Servers}> - {Object.keys(sync.data.mcp).length} MCP Servers - + {Object.keys(sync.data.mcp ?? {}).length} MCP Servers + {([key, item]) => ( { - const providersResponse = providersPromise.then((x) => x.data!) - const providerListResponse = providerListPromise.then((x) => x.data!) + const providersResponse = providersPromise.then((x) => x.data ?? { providers: [], default: {} }) + const providerListResponse = providerListPromise.then( + (x) => x.data ?? { all: [], default: {}, connected: [] }, + ) const capabilitiesResponse = capabilitiesPromise const consoleStateResponse = consoleStatePromise const agentsResponse = agentsPromise.then((x) => x.data ?? []) - const configResponse = configPromise.then((x) => x.data!) + const configResponse = configPromise.then((x) => x.data ?? {}) const sessionListResponse = args.continue ? sessionListPromise : undefined return Promise.all([ @@ -503,14 +505,14 @@ export const { const sessions = responses[6] batch(() => { - setStore("provider", reconcile(providers.providers)) - setStore("provider_default", reconcile(providers.default)) - setStore("provider_next", reconcile(providerList)) + setStore("provider", reconcile(providers?.providers ?? [])) + setStore("provider_default", reconcile(providers?.default ?? {})) + setStore("provider_next", reconcile(providerList ?? { all: [], default: {}, connected: [] })) setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true) - setStore("console_state", reconcile(consoleState)) - setStore("agent", reconcile(agents)) - setStore("config", reconcile(config)) - if (sessions !== undefined) setStore("session", reconcile(sessions)) + setStore("console_state", reconcile(consoleState ?? emptyConsoleState)) + setStore("agent", reconcile(agents ?? [])) + setStore("config", reconcile(config ?? {})) + if (sessions !== undefined) setStore("session", reconcile(sessions ?? [])) }) }) }) @@ -519,7 +521,7 @@ export const { // non-blocking void Promise.all([ ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), - consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), + consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState ?? emptyConsoleState))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))), sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))), @@ -531,7 +533,7 @@ export const { setStore("session_status", reconcile(x.data ?? {})) }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), + sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data ?? undefined))), project.workspace.sync(), ]).then(() => { setStore("status", "complete") diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index fef0ec8eb8dd..97888ffab8f9 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -151,7 +151,7 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status })) }, mcp() { - return Object.entries(sync.data.mcp) + return Object.entries(sync.data.mcp ?? {}) .sort(([a], [b]) => a.localeCompare(b)) .map(([name, item]) => ({ name, diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx index c3a96254e98b..715cf94150e7 100644 --- a/packages/tui/src/routes/session/footer.tsx +++ b/packages/tui/src/routes/session/footer.tsx @@ -10,9 +10,9 @@ export function Footer() { const { theme } = useTheme() const sync = useSync() const route = useRoute() - const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length) - const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed")) - const lsp = createMemo(() => Object.keys(sync.data.lsp)) + const mcp = createMemo(() => Object.values(sync.data.mcp ?? {}).filter((x) => x.status === "connected").length) + const mcpError = createMemo(() => Object.values(sync.data.mcp ?? {}).some((x) => x.status === "failed")) + const lsp = createMemo(() => Object.keys(sync.data.lsp ?? [])) const permissions = createMemo(() => { if (route.data.type !== "session") return [] return sync.data.permission[route.data.sessionID] ?? [] diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index e8a5f2c5a973..a8ffbbd6e113 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -264,7 +264,7 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { } const resolved = Object.fromEntries( - Object.entries(theme.theme) + Object.entries(theme.theme ?? {}) .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity") .map(([key, value]) => { return [key, resolveColor(value as ColorValue)]