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();