diff --git a/docs-site/public/pr-screenshots/models-tabs-layout-drift-fixed.png b/docs-site/public/pr-screenshots/models-tabs-layout-drift-fixed.png new file mode 100644 index 000000000..2040c073a Binary files /dev/null and b/docs-site/public/pr-screenshots/models-tabs-layout-drift-fixed.png differ diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index 73c4901a1..b6ab815ce 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -8,14 +8,17 @@ /* The catalog wants a wider column than the 980px default. - Scoped to a VISIBLE catalog panel, not merely a present one: panels mount lazily and - then stay mounted so drafts survive a tab hop, so a bare `:has(.models-workspace-shell)` - keeps matching after the catalog has been opened once. Routing would then render at - 980px on a direct visit and 1200px afterwards — a width that depends on browsing - history. No surface renders the shell outside a tabpanel any more, so the old - direct-child arm is gone with the standalone pages it served. + Scoped to a VISIBLE catalog or routing panel, not merely a present one: panels mount + lazily and then stay mounted so drafts survive a tab hop, so a bare + `:has(.models-workspace-shell)` keeps matching after the catalog has been opened once. + Routing must share the catalog's 1200px page width or switching between those tabs + shifts the content horizontally. No surface renders the shell outside a tabpanel any + more, so the old direct-child arm is gone with the standalone pages it served. */ -.main-inner:has(#models-panel-catalog:not([hidden]) .models-workspace-shell) { +.main-inner:has( + #models-panel-catalog:not([hidden]), + #models-panel-routing:not([hidden]) +) { max-width: 1200px; } diff --git a/gui/tests/models-provider-head.test.ts b/gui/tests/models-provider-head.test.ts index bfa33beb0..47ea2867f 100644 --- a/gui/tests/models-provider-head.test.ts +++ b/gui/tests/models-provider-head.test.ts @@ -31,6 +31,17 @@ test("Models workspace stacks via content-width container query before mobile dr expect(css).toContain("@media (max-width: 768px)"); }); +test("Models catalog and routing tabs keep the same wide page width", async () => { + const css = await Bun.file(new URL("../src/styles-models-workspace.css", import.meta.url)).text(); + + // Both panels stay mounted after first visit, so the width rule must follow the + // visible panel rather than the catalog shell's historical presence in the DOM. + expect(css).toMatch( + /\.main-inner:has\(\s*#models-panel-catalog:not\(\[hidden\]\),\s*#models-panel-routing:not\(\[hidden\]\)\s*\)\s*\{\s*max-width:\s*1200px;/s, + ); + expect(css).not.toContain("#models-panel-catalog:not([hidden]) .models-workspace-shell"); +}); + test("Models exposes provider and per-model context-window controls (#1073)", async () => { const page = await Bun.file(new URL("../src/pages/Models.tsx", import.meta.url)).text(); const groups = await Bun.file(new URL("../src/models-groups.ts", import.meta.url)).text(); diff --git a/gui/tests/models-workspace-panels.test.tsx b/gui/tests/models-workspace-panels.test.tsx index 252edd46e..bce8f036f 100644 --- a/gui/tests/models-workspace-panels.test.tsx +++ b/gui/tests/models-workspace-panels.test.tsx @@ -91,9 +91,101 @@ async function mountModels(): Promise<{ container: HTMLElement; root: Root }> { return { container, root }; } +/** + * App owns `.main-inner` and the page-width rules that key off visible tab panels. + * happy-dom does not parse the multiline `:has()` selector in the shipped stylesheet, + * so the test injects a single-line form that matches the production rule. + */ +function injectModelsPageWidthCss(doc: Document): void { + const style = doc.createElement("style"); + style.textContent = ` + .main-inner { max-width: 980px; margin: 0 auto; } + .main-inner:has(#models-panel-catalog:not([hidden]), #models-panel-routing:not([hidden])) { max-width: 1200px; } + `; + doc.head.appendChild(style); +} + +async function mountModelsInMainInner(hash = "http://localhost/#models"): Promise<{ + container: HTMLElement; + mainInner: HTMLElement; + root: Root; +}> { + testWindow.location.href = hash; + injectModelsPageWidthCss(document); + const { createRoot } = await import("react-dom/client"); + const outer = document.createElement("div"); + const mainInner = document.createElement("div"); + mainInner.className = "main-inner"; + outer.append(mainInner); + document.body.append(outer); + let root!: Root; + await act(async () => { + root = createRoot(mainInner); + root.render( + + + , + ); + }); + await act(async () => { await Promise.resolve(); }); + return { container: mainInner, mainInner, root }; +} + +function mainInnerMaxWidth(mainInner: HTMLElement): string { + return testWindow.getComputedStyle(mainInner).maxWidth; +} + +async function clickTab(container: HTMLElement, id: string): Promise { + await act(async () => { + (container.querySelector(`#models-tab-${id}`) as HTMLButtonElement).click(); + }); + await act(async () => { await Promise.resolve(); }); +} + const tabs = (container: HTMLElement) => [...container.querySelectorAll('[role="tab"]')] as HTMLButtonElement[]; const panel = (container: HTMLElement, id: string) => container.querySelector(`#models-panel-${id}`); +/* + * The layout drift bug: routing rendered at 980px while catalog used 1200px, and a + * mounted-but-hidden catalog could keep the wide rule latched via the old shell selector. + * Source-string CSS tests cannot see computed width; this one drives real tab switches. + */ +test("catalog and routing keep the same main-inner width across tab switches", async () => { + installFetch(); + const { container, mainInner, root } = await mountModelsInMainInner(); + try { + await act(async () => { await Promise.resolve(); }); + expect(mainInnerMaxWidth(mainInner)).toBe("1200px"); + + await clickTab(container, "routing"); + expect(mainInnerMaxWidth(mainInner)).toBe("1200px"); + expect(panel(container, "catalog")?.hasAttribute("hidden")).toBe(true); + + await clickTab(container, "catalog"); + expect(mainInnerMaxWidth(mainInner)).toBe("1200px"); + expect(panel(container, "routing")?.hasAttribute("hidden")).toBe(true); + } finally { + await act(async () => root.unmount()); + } +}); + +test("routing keeps the wide layout on a direct visit while catalog stays mounted hidden", async () => { + installFetch(); + const { container, mainInner, root } = await mountModelsInMainInner("http://localhost/#models/routing"); + try { + await act(async () => { await Promise.resolve(); }); + expect(panel(container, "catalog")).toBeTruthy(); + expect(panel(container, "catalog")?.hasAttribute("hidden")).toBe(true); + expect(mainInnerMaxWidth(mainInner)).toBe("1200px"); + + await clickTab(container, "catalog"); + await clickTab(container, "routing"); + expect(mainInnerMaxWidth(mainInner)).toBe("1200px"); + } finally { + await act(async () => root.unmount()); + } +}); + test("the strip renders all four tabs with the catalog selected on the bare hash", async () => { installFetch(); const { container, root } = await mountModels();