Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions packages/chat-ui/src/workbench-settings/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";

import {
capacitySectionVisible,
isWorkbenchSettingsSectionId,
workbenchSettingsSections,
WORKBENCH_SETTINGS_SECTION_IDS,
Expand All @@ -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([
Expand Down Expand Up @@ -86,7 +101,9 @@ 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",
Expand All @@ -97,6 +114,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([
Expand All @@ -109,7 +140,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");
});
Expand Down
29 changes: 22 additions & 7 deletions packages/chat-ui/src/workbench-settings/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand All @@ -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[] = [
{
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 17 additions & 12 deletions packages/chat-ui/src/workbench-settings/surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -129,10 +130,14 @@ 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"];
Expand All @@ -146,7 +151,7 @@ export function WorkbenchSettingsSurface({
data: {
data: settings,
benchDefault: bench.contextWindow,
capacityAvailable,
capacityProbe,
},
});
})
Expand Down Expand Up @@ -184,7 +189,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) {
Expand Down
140 changes: 140 additions & 0 deletions packages/chat-ui/test/capacity-probe.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof WorkbenchSettingsSurface>[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<Parameters<typeof WorkbenchSettingsSurface>[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<Response>);
}) {
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");
});
});
Loading