From 5fac7e513c9c645f10162426d16d9dbef7dbb142 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 20 Aug 2026 21:32:13 -0700 Subject: [PATCH] Omit a null hostId from thread tabs responses so older mobile builds still sync Installed mobile builds parse the tabs routes with a strict copy of the contract that predates hostId. The server now emits hostId: null, so those builds reject every response and show "Couldn't sync tabs" when a host-file link opens a panel tab. Omit the field when it is null; current clients default it back. Co-Authored-By: Claude --- apps/server/src/routes/threads/tabs.ts | 47 +++++++++++++- .../test/public/public-thread-tabs.test.ts | 63 +++++++++++++++++++ .../server-contract/src/api/thread-tabs.ts | 5 ++ packages/server-contract/src/public-api.ts | 6 +- 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/apps/server/src/routes/threads/tabs.ts b/apps/server/src/routes/threads/tabs.ts index 430a089658..532f2170c5 100644 --- a/apps/server/src/routes/threads/tabs.ts +++ b/apps/server/src/routes/threads/tabs.ts @@ -4,13 +4,49 @@ import { threadTabsSchema, typedRoutes, type PublicApiSchema, + type ThreadTab, type ThreadTabsResponse, + type ThreadTabsWireResponse, } from "@bb/server-contract"; import type { Hono } from "hono"; import { ApiError } from "../../errors.js"; import { requirePublicThread } from "../../services/lib/entity-lookup.js"; import type { AppDeps } from "../../types.js"; +type WireThreadTab = ThreadTabsWireResponse["tabs"][number]; + +/** + * Omit `hostId` from the wire when it is `null`. The field arrived with a + * `.default(null)` after mobile builds had already shipped a `.strict()` copy + * of this schema, and installed apps cannot update with the server: an + * unknown key makes their SDK reject every tabs response, and the panel + * stops syncing ("Couldn't sync tabs"). Current clients parse the omission + * back to `null` through the default, so nothing is lost on the wire. + */ +function toWireThreadTab(tab: ThreadTab): WireThreadTab { + if (tab.kind === "host-file-preview") { + const { hostId, ...rest } = tab; + return hostId === null ? rest : tab; + } + if ( + tab.kind === "plugin-panel" && + tab.fileOpenerOwner?.kind === "host-file-preview" + ) { + const { hostId, ...owner } = tab.fileOpenerOwner; + return hostId === null ? { ...tab, fileOpenerOwner: owner } : tab; + } + return tab; +} + +function toWireThreadTabsResponse( + response: ThreadTabsResponse, +): ThreadTabsWireResponse { + return { + revision: response.revision, + tabs: response.tabs.map(toWireThreadTab), + }; +} + function readThreadTabs(deps: AppDeps, threadId: string): ThreadTabsResponse { const stored = getStoredThreadTabs(deps.db, threadId); if (!stored) { @@ -33,7 +69,9 @@ export function registerThreadTabRoutes(app: Hono, deps: AppDeps): void { get(routes.tabs, (context) => { const thread = requirePublicThread(deps.db, context.req.param("id")); - return context.json(readThreadTabs(deps, thread.id)); + return context.json( + toWireThreadTabsResponse(readThreadTabs(deps, thread.id)), + ); }); put(routes.updateTabs, (context, payload) => { @@ -52,6 +90,11 @@ export function registerThreadTabRoutes(app: Hono, deps: AppDeps): void { ); } deps.hub.notifyThread(thread.id, ["tabs-changed"]); - return context.json({ revision: result.revision, tabs: payload.tabs }); + return context.json( + toWireThreadTabsResponse({ + revision: result.revision, + tabs: payload.tabs, + }), + ); }); } diff --git a/apps/server/test/public/public-thread-tabs.test.ts b/apps/server/test/public/public-thread-tabs.test.ts index 6f1a492b1c..9319f5eb29 100644 --- a/apps/server/test/public/public-thread-tabs.test.ts +++ b/apps/server/test/public/public-thread-tabs.test.ts @@ -132,6 +132,69 @@ describe("public thread tabs", () => { }); }); + it("omits a null hostId on the wire so strict older clients still parse", async () => { + await withTestHarness(async (harness) => { + const { thread } = seedThreadFixture(harness); + const tabs: readonly ThreadTab[] = [ + { + environmentId: "env_1", + hostId: null, + id: "host-file-default", + kind: "host-file-preview", + lineRange: null, + path: "/tmp/default.png", + threadId: thread.id, + }, + { + environmentId: null, + hostId: "host_1", + id: "host-file-explicit", + kind: "host-file-preview", + lineRange: null, + path: "/tmp/explicit.png", + threadId: null, + }, + { + actionId: "inspect", + fileOpenerOwner: { + environmentId: "env_1", + hostId: null, + kind: "host-file-preview", + tab: { lineRange: null, path: "/tmp/owned.png" }, + threadId: thread.id, + }, + id: "plugin-panel", + kind: "plugin-panel", + paramsJson: null, + pluginId: "example", + title: "Inspector", + }, + ]; + + const updateResponse = await putTabs(harness, thread.id, { + expectedRevision: 0, + tabs, + }); + expect(updateResponse.status).toBe(200); + const getResponse = await getTabs(harness, thread.id); + expect(getResponse.status).toBe(200); + + for (const body of [ + await readJson(updateResponse), + await readJson(getResponse), + ]) { + const wire = body as { + tabs: Array & { fileOpenerOwner?: object }>; + }; + expect(wire.tabs[0]).not.toHaveProperty("hostId"); + expect(wire.tabs[1]).toHaveProperty("hostId", "host_1"); + expect(wire.tabs[2]?.fileOpenerOwner).not.toHaveProperty("hostId"); + // Current clients still read the omission back as null. + expect(threadTabsResponseSchema.parse(body).tabs).toEqual(tabs); + } + }); + }); + it("rejects duplicate tab ids and unknown threads", async () => { await withTestHarness(async (harness) => { const { thread } = seedThreadFixture(harness); diff --git a/packages/server-contract/src/api/thread-tabs.ts b/packages/server-contract/src/api/thread-tabs.ts index 57f20bf336..573c873a4d 100644 --- a/packages/server-contract/src/api/thread-tabs.ts +++ b/packages/server-contract/src/api/thread-tabs.ts @@ -204,6 +204,11 @@ export const threadTabsResponseSchema = z }) .strict(); export type ThreadTabsResponse = z.infer; +/** + * The JSON the tabs routes send: defaulted fields (`hostId`) may be absent. + * Clients parse it with `threadTabsResponseSchema`, which fills them in. + */ +export type ThreadTabsWireResponse = z.input; export const updateThreadTabsRequestSchema = z .object({ diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 5a7ec84001..0c3ea02e71 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -211,7 +211,7 @@ import type { WorkspacePathListResponse, } from "./api-types.js"; import type { - ThreadTabsResponse, + ThreadTabsWireResponse, UpdateThreadTabsRequest, } from "./api/thread-tabs.js"; import { updateThreadTabsRequestSchema } from "./api/thread-tabs.js"; @@ -1106,7 +1106,7 @@ export const publicApiRoutes = { path: "/threads/:id/tabs", method: "get", request: noRequest(), - response: jsonResponse(), + response: jsonResponse(), }), updateTabs: defineRoute({ path: "/threads/:id/tabs", @@ -1115,7 +1115,7 @@ export const publicApiRoutes = { updateThreadTabsRequestSchema, ), response: [ - jsonResponse(), + jsonResponse(), jsonResponse({ status: 409 }), ], }),