From 90932171a46a7178873e04adefc0fe7357107c59 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:17:03 -0700 Subject: [PATCH 1/2] Keep capacity settings visible when provisioner probe fails Closes CL-6828 --- .../src/workbench-settings/model.test.ts | 37 ++++- .../chat-ui/src/workbench-settings/model.ts | 29 +++- .../src/workbench-settings/surface.tsx | 30 ++-- packages/chat-ui/test/capacity-probe.test.tsx | 140 ++++++++++++++++++ 4 files changed, 215 insertions(+), 21 deletions(-) create mode 100644 packages/chat-ui/test/capacity-probe.test.tsx diff --git a/packages/chat-ui/src/workbench-settings/model.test.ts b/packages/chat-ui/src/workbench-settings/model.test.ts index 366130ec6..3ccb288e2 100644 --- a/packages/chat-ui/src/workbench-settings/model.test.ts +++ b/packages/chat-ui/src/workbench-settings/model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { + capacitySectionVisible, isWorkbenchSettingsSectionId, workbenchSettingsSections, WORKBENCH_SETTINGS_SECTION_IDS, @@ -19,6 +20,20 @@ describe("isWorkbenchSettingsSectionId", () => { }); }); +describe("capacitySectionVisible", () => { + test("hides Capacity only when the probe confirmed unavailable", () => { + expect(capacitySectionVisible("unavailable")).toBe(false); + }); + + test("shows Capacity when the provisioner is available", () => { + expect(capacitySectionVisible("available")).toBe(true); + }); + + test("shows Capacity when the probe failed — unknown is not unavailable", () => { + expect(capacitySectionVisible("unknown")).toBe(true); + }); +}); + describe("workbenchSettingsSections", () => { test("workbenches expose the full settings surface: General/Agents in Shared, Notifications in Personal", () => { expect(workbenchSettingsSections("workbench").map((s) => s.id)).toEqual([ @@ -86,7 +101,7 @@ describe("workbenchSettingsSections", () => { test("Capacity appears, after Notifications, when this server offers it", () => { expect( - workbenchSettingsSections("workbench", false, true).map((s) => s.id), + workbenchSettingsSections("workbench", false, "available").map((s) => s.id), ).toEqual([ "general", "members", @@ -97,6 +112,20 @@ describe("workbenchSettingsSections", () => { ]); }); + test("Capacity appears when the probe is unknown — a failed probe is not unavailable", () => { + expect( + workbenchSettingsSections("workbench", false, "unknown").map((s) => s.id), + ).toContain("capacity"); + }); + + test("Capacity stays hidden when the probe confirmed unavailable", () => { + expect( + workbenchSettingsSections("workbench", false, "unavailable").map( + (s) => s.id, + ), + ).not.toContain("capacity"); + }); + test("groups sections Shared / Personal / Danger for the nav", () => { const groups = workbenchSettingsSections("workbench").map((s) => s.group); expect(groups).toEqual([ @@ -109,7 +138,11 @@ describe("workbenchSettingsSections", () => { }); test("Capacity is grouped Shared even though it sits after Notifications in the list", () => { - const withCapacity = workbenchSettingsSections("workbench", false, true); + const withCapacity = workbenchSettingsSections( + "workbench", + false, + "available", + ); const capacity = withCapacity.find((s) => s.id === "capacity"); expect(capacity?.group).toBe("shared"); }); diff --git a/packages/chat-ui/src/workbench-settings/model.ts b/packages/chat-ui/src/workbench-settings/model.ts index 6b5be9890..1b3a48335 100644 --- a/packages/chat-ui/src/workbench-settings/model.ts +++ b/packages/chat-ui/src/workbench-settings/model.ts @@ -42,6 +42,22 @@ export type WorkbenchSettingsSection = { readonly group: WorkbenchSettingsSectionGroup; }; +/** + * Outcome of asking this server whether it can offer dedicated capacity. + * + * - `"available"` — provisioner confirmed present; show Capacity. + * - `"unavailable"` — provisioner confirmed absent; hide Capacity. + * - `"unknown"` — probe failed or has not resolved; must not be treated + * as `"unavailable"` (CL-6828). A transient miss would permanently + * omit the section if folded to false. + */ +export type CapacityProbeState = "unknown" | "available" | "unavailable"; + +/** Capacity stays in the nav unless the probe confirmed no provisioner. */ +export function capacitySectionVisible(probe: CapacityProbeState): boolean { + return probe !== "unavailable"; +} + /** * Sections available for a workbench kind, in nav order. 1:1 chats drop * Members and Danger zone so the surface stays short (owner decision / @@ -56,16 +72,15 @@ export type WorkbenchSettingsSection = { * Defaults to `false` so every existing call site (agent chats, * workbenches) keeps exactly the section list it already had. * - * `hasCapacity` gates Capacity the same way: this server's provisioner - * either can or cannot run a workbench's agents on their own machine — - * a fact this server decides once for everyone, never per conversation - * — so a server without one has nothing here to configure. Defaults to - * `false`, hidden until the caller confirms the feature is live. + * `capacityProbe` gates Capacity: hide only when the probe confirmed + * this server has no provisioner. Defaults to `"unavailable"` (hidden) + * until the caller supplies a real probe outcome — never fold a failed + * probe into that default. */ export function workbenchSettingsSections( workbenchKind: string, isDm = false, - hasCapacity = false, + capacityProbe: CapacityProbeState = "unavailable", ): readonly WorkbenchSettingsSection[] { const sections: WorkbenchSettingsSection[] = [ { @@ -93,7 +108,7 @@ export function workbenchSettingsSections( label: CHAT_STRINGS.workbenchSettingsSectionNotifications, group: "personal", }); - if (hasCapacity) { + if (capacitySectionVisible(capacityProbe)) { sections.push({ id: "capacity", label: CHAT_STRINGS.workbenchSettingsSectionCapacity, diff --git a/packages/chat-ui/src/workbench-settings/surface.tsx b/packages/chat-ui/src/workbench-settings/surface.tsx index fb16c5ee6..5f74ad29f 100644 --- a/packages/chat-ui/src/workbench-settings/surface.tsx +++ b/packages/chat-ui/src/workbench-settings/surface.tsx @@ -39,6 +39,7 @@ import { GeneralSection } from "./general-section"; import { MembersSection } from "./members-section"; import { workbenchSettingsSections } from "./model"; import type { + CapacityProbeState, WorkbenchSettingsSection, WorkbenchSettingsSectionId, } from "./model"; @@ -47,13 +48,13 @@ import { NotificationsSection } from "./notifications-section"; type WorkbenchSettingsData = { readonly data: WorkbenchSettings; readonly benchDefault: number; - /** Whether this server's provisioner can run a workbench's agents on - * their own dedicated machine — a server-wide fact, not per-conversation. - * Gates the Capacity nav item itself (`workbenchSettingsSections`'s - * `hasCapacity`): a server without one has nothing there to configure, - * so the section is absent rather than shown disabled. Defaults to - * `false` on a failed probe — hidden, not a broken control. */ - readonly capacityAvailable: boolean; + /** Tri-state outcome of probing whether this server's provisioner can + * run a workbench's agents on their own dedicated machine — a + * server-wide fact, not per-conversation. Gates the Capacity nav item + * (`workbenchSettingsSections`'s `capacityProbe`): hide only when the + * probe confirmed `"unavailable"`. A failed probe stays `"unknown"` + * so a transient miss does not permanently omit the section (CL-6828). */ + readonly capacityProbe: CapacityProbeState; }; const SECTION_GROUP_ORDER = ["shared", "personal", "danger"] as const; @@ -129,10 +130,15 @@ export function WorkbenchSettingsSurface({ getWorkbenchSettings(tenantId, workbenchId), getBenchChatSettings(tenantId), getCapacityPlacement(tenantId) - .then((result) => result.provisionerAvailable) - .catch(() => false), + .then( + (result): CapacityProbeState => + result.provisionerAvailable ? "available" : "unavailable", + ) + // A failed probe is `"unknown"`, never `"unavailable"` — folding + // the miss into false used to hide Capacity permanently (CL-6828). + .catch((): CapacityProbeState => "unknown"), ]) - .then(([settings, bench, capacityAvailable]) => { + .then(([settings, bench, capacityProbe]) => { if (cancelled) return; const control = contextWindowControlState(settings.contextWindow); const storedPurpose = settings.settings["chat/purpose"]; @@ -146,7 +152,7 @@ export function WorkbenchSettingsSurface({ data: { data: settings, benchDefault: bench.contextWindow, - capacityAvailable, + capacityProbe, }, }); }) @@ -184,7 +190,7 @@ export function WorkbenchSettingsSurface({ const sections = workbenchSettingsSections( ready !== undefined ? ready.data.kind : "workbench", isDm, - ready?.capacityAvailable ?? false, + ready?.capacityProbe ?? "unavailable", ); const firstSection = sections[0]; if (firstSection === undefined) { diff --git a/packages/chat-ui/test/capacity-probe.test.tsx b/packages/chat-ui/test/capacity-probe.test.tsx new file mode 100644 index 000000000..0ade2ce98 --- /dev/null +++ b/packages/chat-ui/test/capacity-probe.test.tsx @@ -0,0 +1,140 @@ +// CL-6828: the workbench settings Capacity nav gate must not fold a failed +// provisioner probe into "unavailable". A transient miss used to hide the +// section permanently via `.catch(() => false)`. +import { afterEach, describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { WorkbenchSettingsSurface } from "../src/workbench-settings"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +function mount(props: Parameters[0]) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(createElement(WorkbenchSettingsSurface, props)); + }); + return container; +} + +afterEach(() => { + if (root !== null) { + act(() => root?.unmount()); + root = null; + } + if (container !== null) { + container.remove(); + container = null; + } +}); + +const settle = () => + act(() => new Promise((resolve) => setTimeout(resolve, 10))); + +function baseProps( + overrides: Partial[0]> = {}, +) { + return { + tenantId: "tnt_1", + workbenchId: "ch_1", + workbenchTitle: "General", + onBack: () => undefined, + onInviteParticipant: () => undefined, + ...overrides, + }; +} + +const settingsFixture = { + id: "ch_1", + title: "General", + kind: "workbench", + pinned: false, + participants: [], + settings: {}, + contextWindow: { value: 20, source: "inherit" }, +}; + +function stubSettingsLoad(options: { + capacity?: Response | (() => Promise); +}) { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { + return json(settingsFixture); + } + if (/\/chat\/bench\/settings$/.test(path)) { + return json({ settings: {}, contextWindow: 20 }); + } + if (/\/sidecar-placement$/.test(path)) { + if (typeof options.capacity === "function") { + return options.capacity(); + } + if (options.capacity !== undefined) { + return options.capacity; + } + return json({ enabled: false, provisionerAvailable: true }); + } + throw new Error(`unexpected fetch: ${path}`); + }) as unknown as typeof fetch; +} + +function navLabels(el: HTMLElement): string[] { + return [...el.querySelectorAll(".workbench-settings-nav-item")].map( + (item) => item.textContent ?? "", + ); +} + +describe("WorkbenchSettingsSurface capacity probe", () => { + test("shows Capacity when the provisioner is available", async () => { + stubSettingsLoad({ + capacity: json({ enabled: false, provisionerAvailable: true }), + }); + const el = mount(baseProps()); + await settle(); + expect(navLabels(el)).toContain("Capacity"); + }); + + test("hides Capacity when the provisioner is confirmed unavailable", async () => { + stubSettingsLoad({ + capacity: json({ enabled: false, provisionerAvailable: false }), + }); + const el = mount(baseProps()); + await settle(); + expect(navLabels(el)).not.toContain("Capacity"); + }); + + test("keeps Capacity visible when the probe fails — does not fold to unavailable", async () => { + stubSettingsLoad({ + capacity: json({}, 500), + }); + const el = mount(baseProps()); + await settle(); + expect(el.querySelector(".workbench-settings-shell")).not.toBeNull(); + expect(navLabels(el)).toContain("Capacity"); + }); + + test("keeps Capacity visible when the probe cannot connect", async () => { + stubSettingsLoad({ + capacity: () => Promise.reject(new TypeError("Failed to fetch")), + }); + const el = mount(baseProps()); + await settle(); + expect(el.querySelector(".workbench-settings-shell")).not.toBeNull(); + expect(navLabels(el)).toContain("Capacity"); + }); +}); From f9d201a104cd0918d73dcb0f42761cffb3387d66 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 09:26:37 -0700 Subject: [PATCH 2/2] Format files changed in this PR --- packages/chat-ui/src/workbench-settings/model.test.ts | 4 +++- packages/chat-ui/src/workbench-settings/surface.tsx | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/chat-ui/src/workbench-settings/model.test.ts b/packages/chat-ui/src/workbench-settings/model.test.ts index 3ccb288e2..6b5fd8d34 100644 --- a/packages/chat-ui/src/workbench-settings/model.test.ts +++ b/packages/chat-ui/src/workbench-settings/model.test.ts @@ -101,7 +101,9 @@ describe("workbenchSettingsSections", () => { test("Capacity appears, after Notifications, when this server offers it", () => { expect( - workbenchSettingsSections("workbench", false, "available").map((s) => s.id), + workbenchSettingsSections("workbench", false, "available").map( + (s) => s.id, + ), ).toEqual([ "general", "members", diff --git a/packages/chat-ui/src/workbench-settings/surface.tsx b/packages/chat-ui/src/workbench-settings/surface.tsx index 5f74ad29f..61f2b25e7 100644 --- a/packages/chat-ui/src/workbench-settings/surface.tsx +++ b/packages/chat-ui/src/workbench-settings/surface.tsx @@ -130,9 +130,8 @@ export function WorkbenchSettingsSurface({ getWorkbenchSettings(tenantId, workbenchId), getBenchChatSettings(tenantId), getCapacityPlacement(tenantId) - .then( - (result): CapacityProbeState => - result.provisionerAvailable ? "available" : "unavailable", + .then((result): CapacityProbeState => + result.provisionerAvailable ? "available" : "unavailable", ) // A failed probe is `"unknown"`, never `"unavailable"` — folding // the miss into false used to hide Capacity permanently (CL-6828).