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
32 changes: 32 additions & 0 deletions devlog/_plan/260926_bug_train_6/030_post_merge_fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Post-merge fixes after batches 6–7 and #5910

A second review of `dev` at `f32f9aabd7` by independent Sol reviewers (union, batch 6 runtime, batch 7 + #5910, new
PR triage) found three verified P2 layout defects from #5910 and one shared CI hang. The batch 6 finding about a failed
replacement surfacing as a generic `upstream_sse_error` matches the Responses post-header path
(`deferProtocolSafeResetRecovery` also preserves the original stream error), so it is a cross-lane design question,
not a batch 6 regression, and is out of scope here.

## Fixes (branch `codex/post-merge-fixes`)

1. Combos overflow in the desktop shell. `quota-summary-bar.css` sizes the Combos shell only when the bar is a direct
child of `.main`; the desktop shell wraps it in `.main-top`, so the `100dvh` shell sits under a 40px strip and the page
overflows by 40px. Add the same two rules (and the ≤760px `height: 100%` rule) keyed on `.main:has(> .main-top)` in
`gui/src/components/app-titlebar.css`, which owns `.main-top`.
2. Narrow desktop window: `.main-top` is `position: sticky; top: 0` like `.mobile-topbar`, and paints over the menu while
scrolling. At `max-width: 760px` make `.main-top` static, as the quota bar already was there.
3. High page zoom: `watchMacTitlebarMetrics` floors the points→CSS ratio at 1, so at 300% on a 360pt window the 80px
inset pushes the 44px menu off a 120px viewport. For ratio < 1 set `--tl-inset` to `ceil(80 × ratio)` and
`--chrome-clear` to that plus 44 (toggle 28 + padding 16, which are CSS pixels); keep `--titlebar-h` at 40 and keep the
ratio ≥ 1 branch unchanged.

Tests: extend `gui/tests/app-titlebar.test.tsx` (zoom-in case: DPR 6 on a 2x window → inset 27px, clear 71px,
titlebar 40px; CSS assertions for the static strip and the `.main-top` Combos rules). GUI screenshot for the PR.

4. CI hang (`test 4/4` batch of `tests/cli/*` timing out at 120s on Linux in dev, #5924 and #5928): root cause is being
investigated in a separate lane; its fix lands in this branch if it is ready and verified, otherwise separately.

## Check

`bun x tsc --noEmit`, `bun run lint:gui`, `bun run build:gui`, gui tests for the titlebar and quota bar, structure and
privacy checks, exact-head CI, then `--admin --match-head-commit`.

6 changes: 4 additions & 2 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { confirmAction } from "./action-dialogs";
import { hostOs, isDesktopShell, isExternalLink, openDesktopUpdatePage } from "./lib/desktop-shell";
import { useSidebarCollapse } from "./use-sidebar-collapse";
import { MainTopStrip, SidebarTopStrip } from "./components/app-titlebar";
import { watchMacTitlebarMetrics } from "./lib/window-chrome";
import { watchMacTitlebarMetrics, windowChromeHandlers } from "./lib/window-chrome";

type Theme = "light" | "dark" | "system";

Expand Down Expand Up @@ -394,7 +394,9 @@ export default function App() {
</ToastNotice>
)}
{/* inert while the drawer is open: keeps focus and assistive tech inside the drawer */}
<header className="mobile-topbar" inert={navOpen}>
{/* At narrow widths the sidebar strip is hidden and the main strip scrolls away, so in
the desktop shell the sticky header is the window's drag surface. */}
<header className="mobile-topbar" inert={navOpen} {...(desktopShell ? windowChromeHandlers() : {})}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide a usable drag target in the narrow desktop header

At widths up to 760px, once .main-top scrolls away, this header is intended to become the desktop window's drag surface, but nearly all of it is occupied by buttons: the menu and action controls are buttons, and the brand is also a button whose flex: 1 1 auto fills the remaining width. windowChromeHandlers() deliberately ignores events originating inside any button, leaving only the 2px gaps and narrow outer padding as draggable targets. In a narrow Tauri window users therefore still have no practical way to drag or double-click-maximize the window after scrolling; reserve an explicit non-interactive drag region or otherwise keep a usable part of the header outside the button elements.

Useful? React with 👍 / 👎.

<button ref={menuBtnRef} type="button" className="menu-toggle" onClick={() => setNavOpen(o => !o)}
aria-expanded={navOpen} aria-controls="app-sidebar"
aria-label={t(navOpen ? "nav.closeMenu" : "nav.openMenu")} title={t(navOpen ? "nav.closeMenu" : "nav.openMenu")}>
Expand Down
24 changes: 24 additions & 0 deletions gui/src/components/app-titlebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@
border-bottom: none;
}

/*
Combos is a fixed 100dvh shell. quota-summary-bar.css sizes it only when the bar is a
direct child of .main (the browser dashboard); in the desktop shell the bar lives inside
.main-top, which is always present, so the same column layout keys on the strip here.
*/
.main:has(> .main-top):has(> .main-inner--combos .combos-workspace-shell) {
display: flex;
flex-direction: column;
height: 100dvh;
}
.main:has(> .main-top) > .main-inner.main-inner--combos:has(.combos-workspace-shell) {
flex: 1 1 auto;
min-height: 0;
height: auto;
}

/* Collapsed = the sidebar is gone entirely: no column, no rail, no background.
Only the fixed strip survives, floating over the main strip's left edge. */
@media (min-width: 761px) {
Expand Down Expand Up @@ -143,6 +159,14 @@
control starts below their row. Both clearances grow when page zoom shrinks. */
@media (max-width: 760px) {
.sidebar-top { display: none; }
/* The sticky mobile header owns the top edge here. A sticky strip at top: 0 would paint
over its menu while scrolling, so the strip scrolls with the page like the bare bar. */
.main-top { position: static; z-index: auto; }
/* The grid already reserves the header's row; fill the main row, not a full viewport. */
.main:has(> .main-top):has(> .main-inner--combos .combos-workspace-shell) {
height: 100%;
min-height: 0;
}
.app--macos .mobile-topbar { padding-left: var(--tl-inset, 80px); }
.app--macos .sidebar.open { padding-top: calc(var(--titlebar-h, 40px) + 18px); }
}
Expand Down
16 changes: 13 additions & 3 deletions gui/src/lib/window-chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,21 @@ export function watchMacTitlebarMetrics(app: HTMLElement): () => void {

const apply = () => {
const dpr = window.devicePixelRatio;
const ratio = Number.isFinite(dpr) && dpr > 0 ? Math.max(1, windowScale / dpr) : 1;
const scale = Number.isFinite(dpr) && dpr > 0 ? windowScale / dpr : 1;
const ratio = Math.max(1, scale);
app.classList.toggle("app--reduced-zoom", ratio > 1);
app.style.setProperty("--tl-inset", `${Math.ceil(80 * ratio)}px`);
app.style.setProperty("--titlebar-h", `${Math.ceil(40 * ratio)}px`);
app.style.setProperty("--chrome-clear", `${Math.ceil(124 * ratio)}px`);
if (scale >= 1) {
app.style.setProperty("--tl-inset", `${Math.ceil(80 * ratio)}px`);
app.style.setProperty("--chrome-clear", `${Math.ceil(124 * ratio)}px`);
return;
}
// Zoomed in: the lights (fixed in window points) now cover fewer CSS pixels, while the
// toggle and its padding (28 + 16) grow with the page. Shrinking only the lights' share
// keeps a 44px menu on screen in a 360pt window at 300%. The row keeps its 40px floor.
const inset = Math.ceil(80 * scale);
app.style.setProperty("--tl-inset", `${inset}px`);
app.style.setProperty("--chrome-clear", `${inset + 44}px`);
};
const readScale = () => {
if (!core?.invoke) return;
Expand Down
25 changes: 25 additions & 0 deletions gui/tests/app-titlebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,31 @@ test("macOS titlebar clearance follows page zoom and monitor scale", async () =>
stop();
});

test("zooming in shrinks only the lights' clearance so the narrow menu stays on screen", async () => {
win.__TAURI__ = { core: { invoke: async () => 2 } };
// 300% page zoom on a Retina window: DPR 6, so one CSS pixel is a third of a point.
Object.defineProperty(win, "devicePixelRatio", { configurable: true, value: 6 });
const stop = watchMacTitlebarMetrics(host);
await Promise.resolve();
expect(host.style.getPropertyValue("--tl-inset")).toBe("27px");
expect(host.style.getPropertyValue("--chrome-clear")).toBe("71px");
expect(host.style.getPropertyValue("--titlebar-h")).toBe("40px");
expect(host.classList.contains("app--reduced-zoom")).toBe(false);
// A 360pt window at 300% is 120 CSS pixels: the inset plus the 44px menu must fit.
expect(27 + 44).toBeLessThanOrEqual(120);
stop();
});

test("the strip yields to the mobile header and still sizes the desktop Combos shell", () => {
const css = readFileSync(new URL("../src/components/app-titlebar.css", import.meta.url), "utf8");
const app = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8");
const mobile = css.slice(css.indexOf("@media (max-width: 760px)"));
expect(mobile).toContain(".main-top { position: static; z-index: auto; }");
expect(css).toContain(".main:has(> .main-top):has(> .main-inner--combos .combos-workspace-shell) {");
expect(css).toContain(".main:has(> .main-top) > .main-inner.main-inner--combos:has(.combos-workspace-shell) {");
expect(app).toContain('<header className="mobile-topbar" inert={navOpen} {...(desktopShell ? windowChromeHandlers() : {})}>');
});

test("the narrow macOS strip and drawer reserve the native controls", () => {
const css = readFileSync(new URL("../src/components/app-titlebar.css", import.meta.url), "utf8");
const lib = readFileSync(new URL("../../desktop/src-tauri/src/lib.rs", import.meta.url), "utf8");
Expand Down
58 changes: 40 additions & 18 deletions tests/cli/cli-config-show-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* that leaked into a round trip would be a worse bug than the one it fixes.
*/
import { describe, expect, setDefaultTimeout, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
Expand All @@ -30,15 +30,33 @@ import { SPAWN_BUDGET_MS } from "../helpers/test-budget";
const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url)));
const cliPath = join(repoRoot, "src", "cli", "index.ts");
const isolatedCodexHome = mkdtempSync(join(tmpdir(), "ocx-config-client-codex-"));
const CLI_CHILD_DEADLINE_MS = SPAWN_BUDGET_MS - 5_000;

setDefaultTimeout(SPAWN_BUDGET_MS);

function runCli(args: string[], home: string) {
return spawnSync(process.execPath, [cliPath, ...args], {
cwd: repoRoot,
env: { ...process.env, CODEX_HOME: isolatedCodexHome, OPENCODEX_HOME: home },
encoding: "utf8",
timeout: SPAWN_BUDGET_MS - 5_000,
function runCli(args: string[], home: string): Promise<{ status: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cliPath, ...args], {
cwd: repoRoot,
env: { ...process.env, CODEX_HOME: isolatedCodexHome, OPENCODEX_HOME: home },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; });
child.stderr.setEncoding("utf8").on("data", chunk => { stderr += chunk; });
const deadline = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`ocx config child exceeded ${CLI_CHILD_DEADLINE_MS}ms`));
}, CLI_CHILD_DEADLINE_MS);
child.once("error", error => {
clearTimeout(deadline);
reject(error);
});
child.once("close", status => {
clearTimeout(deadline);
resolve({ status, stdout, stderr });
});
});
}

Expand Down Expand Up @@ -157,10 +175,10 @@ describe("remoteHubConfigNote", () => {
});

describe("ocx config show on a client", () => {
test("leads with _remoteHub and omits the priorCatalog blob", () => {
test("leads with _remoteHub and omits the priorCatalog blob", async () => {
const home = clientHome();
try {
const result = runCli(["config", "show"], home);
const result = await runCli(["config", "show"], home);
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout);
// First key: it must be read before the empty providers map, not after it.
Expand All @@ -180,43 +198,47 @@ describe("ocx config show on a client", () => {
}
});

test("config get on the blob is omitted too, not printed through a side door", () => {
test("config get on the blob is omitted too, not printed through a side door", async () => {
const home = clientHome();
try {
const result = runCli(["config", "get", "client.priorCatalog"], home);
// A synchronous spawn blocked this timer and left the Linux batch deadline as the first signal.
let eventLoopAdvanced = false;
setTimeout(() => { eventLoopAdvanced = true; }, 0);
const result = await runCli(["config", "get", "client.priorCatalog"], home);
expect(eventLoopAdvanced).toBe(true);
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe(`<omitted: ${PRIOR_CATALOG.length} bytes>`);
} finally {
removeTreeWithRetry(home);
}
});

test("config export carries the real config and stays validate-clean", () => {
test("config export carries the real config and stays validate-clean", async () => {
const home = clientHome();
const exported = join(home, "exported.json");
try {
const result = runCli(["config", "export", exported], home);
const result = await runCli(["config", "export", exported], home);
expect(result.status).toBe(0);
const text = readFileSync(exported, "utf8");
// A synthetic annotation that leaked into an export would break the round trip.
expect(text).not.toContain("_remoteHub");
// And the export is the REAL config: the omission marker is a display concern only.
const parsed = JSON.parse(text);
expect(parsed.client.priorCatalog).toBe(PRIOR_CATALOG);
const validated = runCli(["config", "validate", exported], home);
const validated = await runCli(["config", "validate", exported], home);
expect(validated.status).toBe(0);
expect(validated.stdout).toContain("Config is valid.");
} finally {
removeTreeWithRetry(home);
}
});

test("a client holding no data-plane token is not reported as connected", () => {
test("a client holding no data-plane token is not reported as connected", async () => {
// End to end, because the hardcoded `true` lived at the call site's expense: `ocx config
// show` is what an agent reads, and this is the machine that cannot reach its hub at all.
const home = clientHome({ token: null });
try {
const result = runCli(["config", "show"], home);
const result = await runCli(["config", "show"], home);
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed._remoteHub.connected).toBe(false);
Expand All @@ -227,10 +249,10 @@ describe("ocx config show on a client", () => {
}
});

test("a standalone machine's output is unannotated", () => {
test("a standalone machine's output is unannotated", async () => {
const home = standaloneHome();
try {
const result = runCli(["config", "show"], home);
const result = await runCli(["config", "show"], home);
expect(result.status).toBe(0);
expect(result.stdout).not.toContain("_remoteHub");
expect(Object.keys(JSON.parse(result.stdout))).not.toContain("_remoteHub");
Expand Down
Loading