Skip to content

Commit b749051

Browse files
authored
Merge pull request #186 from pylon-code/upstream/2026-08-29-provider-settings
fix(web): clean up the provider settings list and editor
2 parents f2a29ee + 16d0c75 commit b749051

4 files changed

Lines changed: 544 additions & 301 deletions

File tree

apps/web/src/components/settings/ProviderInstanceCard.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createElement } from "react";
22
import { renderToStaticMarkup } from "react-dom/server";
33
import { describe, expect, it } from "vite-plus/test";
44
import {
5+
EnvironmentId,
56
ProviderDriverKind,
67
ProviderInstanceId,
78
type ServerProvider,
@@ -11,6 +12,52 @@ import { DEFAULT_TIMESTAMP_FORMAT } from "@t3tools/contracts/settings";
1112

1213
import { deriveProviderModelsForDisplay, ProviderInstanceCard } from "./ProviderInstanceCard";
1314

15+
/**
16+
* The inner markup of every `tag` element carrying `inert=""`. These tests
17+
* render to a string with no DOM available, so nesting is resolved by
18+
* balancing open and close tags.
19+
*/
20+
function inertRegions(markup: string, tag: string): ReadonlyArray<string> {
21+
const regions: Array<string> = [];
22+
const fenceOpen = `<${tag} inert=""`;
23+
const anyOpen = `<${tag}`;
24+
const close = `</${tag}>`;
25+
let cursor = 0;
26+
for (;;) {
27+
const start = markup.indexOf(fenceOpen, cursor);
28+
if (start === -1) return regions;
29+
const bodyStart = markup.indexOf(">", start) + 1;
30+
let depth = 1;
31+
let index = bodyStart;
32+
while (depth > 0) {
33+
const nextOpen = markup.indexOf(anyOpen, index);
34+
const nextClose = markup.indexOf(close, index);
35+
if (nextClose === -1) break;
36+
if (nextOpen !== -1 && nextOpen < nextClose) {
37+
depth += 1;
38+
index = nextOpen + anyOpen.length;
39+
} else {
40+
depth -= 1;
41+
index = nextClose + close.length;
42+
}
43+
}
44+
regions.push(markup.slice(bodyStart, Math.max(bodyStart, index - close.length)));
45+
cursor = index;
46+
}
47+
}
48+
49+
/**
50+
* The opening tag of the button carrying `ariaLabel`, with its class attribute
51+
* stripped so an assertion on `disabled=""` cannot be satisfied by the
52+
* `disabled:` variants baked into the button's class list.
53+
*/
54+
function buttonTag(markup: string, ariaLabel: string): string {
55+
const at = markup.indexOf(`aria-label="${ariaLabel}"`);
56+
if (at === -1) return "";
57+
const start = markup.lastIndexOf("<button", at);
58+
return markup.slice(start, markup.indexOf(">", at) + 1).replace(/ class="[^"]*"/g, "");
59+
}
60+
1461
describe("deriveProviderModelsForDisplay", () => {
1562
it("uses current config custom models instead of stale live custom rows", () => {
1663
const liveModels: ReadonlyArray<ServerProviderModel> = [
@@ -89,4 +136,146 @@ describe("deriveProviderModelsForDisplay", () => {
89136
expect(markup).not.toContain("Account email");
90137
expect(markup).not.toContain("developer@example.com");
91138
});
139+
140+
it("surfaces a failed probe message in both the list row and the editor", () => {
141+
const instanceId = ProviderInstanceId.make("codex_work");
142+
const driver = ProviderDriverKind.make("codex");
143+
const message =
144+
"Codex app-server provider probe failed: Cannot create Codex shadow home entry 'auth.json' because '/home/me/.codex-t3/work/auth.json' already exists and is not a symlink.";
145+
const liveProvider: ServerProvider = {
146+
instanceId,
147+
driver,
148+
enabled: true,
149+
installed: true,
150+
version: null,
151+
status: "error",
152+
auth: { status: "unknown" },
153+
checkedAt: "2026-08-28T12:00:00.000Z",
154+
models: [],
155+
slashCommands: [],
156+
skills: [],
157+
message,
158+
};
159+
const props = {
160+
instanceId,
161+
instance: { driver },
162+
driverOption: undefined,
163+
liveProvider,
164+
timestampFormat: DEFAULT_TIMESTAMP_FORMAT,
165+
onUpdate: () => undefined,
166+
hiddenModels: [],
167+
favoriteModels: [],
168+
modelOrder: [],
169+
onHiddenModelsChange: () => undefined,
170+
onFavoriteModelsChange: () => undefined,
171+
onModelOrderChange: () => undefined,
172+
} as const;
173+
174+
for (const mode of ["list", "editor"] as const) {
175+
const markup = renderToStaticMarkup(createElement(ProviderInstanceCard, { ...props, mode }));
176+
expect(markup).toContain("Unavailable");
177+
expect(markup).toContain("is not a symlink");
178+
}
179+
});
180+
181+
// Fork-only affordances that upstream's card has no equivalent of. The
182+
// upstream cleanup rebuilt the editor header around an inert fence for the
183+
// write actions, so this guards three things at once: the controls still
184+
// render, each chevron's disabled state tracks its handler rather than being
185+
// stuck off, and the fence lands on the write actions instead of on the
186+
// status line's email reveal.
187+
it("fences the drain-order chevrons and the in-app sign-in without freezing the email reveal", () => {
188+
const instanceId = ProviderInstanceId.make("claude_work");
189+
const driver = ProviderDriverKind.make("claudeAgent");
190+
const liveProvider: ServerProvider = {
191+
instanceId,
192+
driver,
193+
enabled: true,
194+
installed: true,
195+
version: "1.0.0",
196+
status: "warning",
197+
// Signed out but with a known address, so the sign-in button and the
198+
// redacted email both render and can be checked on opposite sides of
199+
// the fence.
200+
auth: { status: "unauthenticated", email: "work@example.com" },
201+
checkedAt: "2026-08-28T12:00:00.000Z",
202+
models: [],
203+
slashCommands: [],
204+
skills: [],
205+
};
206+
207+
const render = (
208+
drainOrder: {
209+
readonly position: number;
210+
readonly total: number;
211+
readonly onMoveUp?: (() => void) | undefined;
212+
readonly onMoveDown?: (() => void) | undefined;
213+
},
214+
readOnly: boolean,
215+
) =>
216+
renderToStaticMarkup(
217+
createElement(ProviderInstanceCard, {
218+
instanceId,
219+
environmentId: EnvironmentId.make("env-1"),
220+
instance: { driver, displayName: "Work account" },
221+
driverOption: undefined,
222+
liveProvider,
223+
mode: "editor",
224+
readOnly,
225+
timestampFormat: DEFAULT_TIMESTAMP_FORMAT,
226+
drainOrder,
227+
onUpdate: () => undefined,
228+
hiddenModels: [],
229+
favoriteModels: [],
230+
modelOrder: [],
231+
onHiddenModelsChange: () => undefined,
232+
onFavoriteModelsChange: () => undefined,
233+
onModelOrderChange: () => undefined,
234+
}),
235+
);
236+
237+
// Middle of three accounts, so both chevrons carry a handler. A chevron
238+
// that had gone permanently disabled fails here instead of passing on the
239+
// strength of its label alone.
240+
const middle = render(
241+
{ position: 1, total: 3, onMoveUp: () => undefined, onMoveDown: () => undefined },
242+
false,
243+
);
244+
expect(buttonTag(middle, "Use Work account earlier (currently 2 of 3)")).not.toContain(
245+
'disabled=""',
246+
);
247+
expect(buttonTag(middle, "Use Work account later (currently 2 of 3)")).not.toContain(
248+
'disabled=""',
249+
);
250+
expect(middle).toContain("Sign in");
251+
expect(inertRegions(middle, "span")).toHaveLength(0);
252+
253+
// First of three: only "later" has a handler, so "earlier" is disabled by
254+
// its position at the end of the list.
255+
const first = render({ position: 0, total: 3, onMoveDown: () => undefined }, false);
256+
expect(buttonTag(first, "Use Work account earlier (currently 1 of 3)")).toContain(
257+
'disabled=""',
258+
);
259+
expect(buttonTag(first, "Use Work account later (currently 1 of 3)")).not.toContain(
260+
'disabled=""',
261+
);
262+
263+
// Read-only sessions freeze the write actions inside inert fences. The
264+
// status line stays outside them so the email reveal keeps working, which
265+
// is the whole reason the blanket header wrapper was split up.
266+
const readOnly = render(
267+
{ position: 1, total: 3, onMoveUp: () => undefined, onMoveDown: () => undefined },
268+
true,
269+
);
270+
const fences = inertRegions(readOnly, "span");
271+
expect(
272+
fences.some((region) => region.includes("Use Work account earlier (currently 2 of 3)")),
273+
).toBe(true);
274+
expect(
275+
fences.some((region) => region.includes("Use Work account later (currently 2 of 3)")),
276+
).toBe(true);
277+
expect(fences.some((region) => region.includes("Sign in"))).toBe(true);
278+
expect(readOnly).toContain('aria-label="Toggle account email visibility"');
279+
expect(fences.some((region) => region.includes("Toggle account email visibility"))).toBe(false);
280+
});
92281
});

0 commit comments

Comments
 (0)