From 12dcf1309d5f430f9d2c0dde3cd387b85cb34bcd Mon Sep 17 00:00:00 2001 From: CY2 <40905444+cy2s@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:32:12 +0800 Subject: [PATCH 1/2] fix(renderer): compact composer controls in narrow layouts --- .../src/renderer-composer-dom.ts | 8 ++- .../src/renderer-model-picker.ts | 1 + .../src/renderer-permission-mode-picker.ts | 5 ++ .../src/renderer-trigger-chip-style.ts | 58 +++++++++++++++++++ .../src/renderer-usage-control.ts | 11 +++- .../test/renderer-trigger-chip-style.test.ts | 39 ++++++++++++- tests/e2e/renderer-usage.spec.ts | 45 +++++++++++++- 7 files changed, 161 insertions(+), 6 deletions(-) diff --git a/packages/renderer-extension/src/renderer-composer-dom.ts b/packages/renderer-extension/src/renderer-composer-dom.ts index d32318af6..b7dba3038 100644 --- a/packages/renderer-extension/src/renderer-composer-dom.ts +++ b/packages/renderer-extension/src/renderer-composer-dom.ts @@ -54,6 +54,7 @@ import { mountRendererHarnessCommandControl, type RendererHarnessCommandControl, } from "./renderer-harness-command-control.js"; +import { observeRendererCompactComposer } from "./renderer-trigger-chip-style.js"; export { CONTROL_ATTRIBUTE }; export type ExternalModelControlView = RendererModelControlView; @@ -103,6 +104,7 @@ export interface ComposerAgentControl { harnessCommands: RendererHarnessCommandControl; sendButton: HTMLButtonElement; sendDisabledBeforeSwitch: boolean | null; + stopCompactLayout?: () => void; } export function eventElement(target: EventTarget | null): Element | null { @@ -719,7 +721,7 @@ export function mountComposerAgentControl( } if (!toolbar) composer.append(modelPicker.root, picker.root); - const control = { + const control: ComposerAgentControl = { composer, composerId, root: picker.root, @@ -765,10 +767,11 @@ export function mountComposerAgentControl( harnessCommands, sendButton, sendDisabledBeforeSwitch: null, - } satisfies ComposerAgentControl; + }; refreshTrailingClusterPlacement(control); refreshUsagePlacement(control); refreshCreditsPlacement(control); + control.stopCompactLayout = observeRendererCompactComposer(composer); return control; } @@ -883,6 +886,7 @@ export function renderComposerAgentControl( } export function disposeComposerAgentControl(control: ComposerAgentControl): void { + control.stopCompactLayout?.(); if (control.sendDisabledBeforeSwitch !== null) { control.sendButton.disabled = nativeSendDisabled( control.sendButton, diff --git a/packages/renderer-extension/src/renderer-model-picker.ts b/packages/renderer-extension/src/renderer-model-picker.ts index 52abd4325..b4d686c5b 100644 --- a/packages/renderer-extension/src/renderer-model-picker.ts +++ b/packages/renderer-extension/src/renderer-model-picker.ts @@ -393,6 +393,7 @@ export function mountRendererModelPicker( label.style.whiteSpace = "nowrap"; const thinkingLabel = document.createElement("span"); + thinkingLabel.dataset.codexhostModelThinking = ""; thinkingLabel.style.color = "var(--color-text-tertiary, #8f8f8f)"; thinkingLabel.style.flex = "0 1 auto"; thinkingLabel.style.maxWidth = "72px"; diff --git a/packages/renderer-extension/src/renderer-permission-mode-picker.ts b/packages/renderer-extension/src/renderer-permission-mode-picker.ts index c42639df0..6d5f51226 100644 --- a/packages/renderer-extension/src/renderer-permission-mode-picker.ts +++ b/packages/renderer-extension/src/renderer-permission-mode-picker.ts @@ -187,10 +187,12 @@ export function mountRendererPermissionModePicker( applyRendererTriggerChipSqueezeTrigger(trigger); const shield = document.createElement("span"); + shield.dataset.codexhostPermissionShield = ""; shield.className = "inline-flex shrink-0 items-center"; shield.append(icon(Shield, 15)); const label = document.createElement("span"); + label.dataset.codexhostPermissionLabel = ""; label.className = "truncate"; label.style.flex = "1 1 auto"; label.style.minWidth = "0"; @@ -199,15 +201,18 @@ export function mountRendererPermissionModePicker( label.style.whiteSpace = "nowrap"; const chevron = document.createElement("span"); + chevron.dataset.codexhostPermissionChevron = ""; chevron.className = "inline-flex shrink-0 items-center"; chevron.style.color = "var(--color-text-tertiary, #8f8f8f)"; chevron.append(icon(ChevronDown, 14)); const lockMark = document.createElement("span"); + lockMark.dataset.codexhostPermissionLock = ""; lockMark.className = "inline-flex shrink-0 items-center"; lockMark.style.color = "var(--color-text-tertiary, #8f8f8f)"; lockMark.hidden = true; lockMark.append(icon(Lock, 14)); const errorMark = document.createElement("span"); + errorMark.dataset.codexhostPermissionError = ""; errorMark.className = "inline-flex shrink-0 items-center"; errorMark.style.color = "var(--color-text-danger, #c2413b)"; errorMark.hidden = true; diff --git a/packages/renderer-extension/src/renderer-trigger-chip-style.ts b/packages/renderer-extension/src/renderer-trigger-chip-style.ts index 6a837d90b..29eedbf19 100644 --- a/packages/renderer-extension/src/renderer-trigger-chip-style.ts +++ b/packages/renderer-extension/src/renderer-trigger-chip-style.ts @@ -23,6 +23,8 @@ const STYLE_ATTRIBUTE = "data-codexhost-trigger-chip-style"; export const TRIGGER_CHIP_CLASS = "codexhost-trigger-chip"; export const TRIGGER_CHIP_ROOT_CLASS = "codexhost-trigger-chip-root"; +const COMPACT_COMPOSER_ATTRIBUTE = "data-codexhost-compact-composer"; +const COMPACT_COMPOSER_WIDTH = 420; /** Floor so a squeezed chip remains clickable. */ export const TRIGGER_CHIP_SQUEEZE_MIN_WIDTH = "48px"; @@ -76,6 +78,27 @@ export function applyRendererTriggerChipSqueezeTrigger(trigger: HTMLElement): vo trigger.style.overflow = "hidden"; } +/** Follow the Composer width, which also changes when a sidebar opens. */ +export function observeRendererCompactComposer(composer: Element): () => void { + const update = (width: number): void => { + if (width > 0) { + composer.toggleAttribute(COMPACT_COMPOSER_ATTRIBUTE, width <= COMPACT_COMPOSER_WIDTH); + } + }; + update(composer.getBoundingClientRect().width); + if (typeof ResizeObserver === "undefined") + return () => composer.removeAttribute(COMPACT_COMPOSER_ATTRIBUTE); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) update(entry.contentRect.width); + }); + observer.observe(composer); + return () => { + observer.disconnect(); + composer.removeAttribute(COMPACT_COMPOSER_ATTRIBUTE); + }; +} + export function ensureRendererTriggerChipStyle(ownerDocument: Document): void { if (ownerDocument.querySelector(`style[${STYLE_ATTRIBUTE}]`)) return; const style = ownerDocument.createElement("style"); @@ -114,6 +137,41 @@ export function ensureRendererTriggerChipStyle(ownerDocument: Document): void { cursor: not-allowed; opacity: 0.4; } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-model-control] [data-codexhost-model-thinking] { + display: none !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-permission-mode-control] { + min-width: 30px !important; + max-width: 30px !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-permission-mode-control] .${TRIGGER_CHIP_CLASS} { + padding-inline: 6px !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-permission-label], + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-permission-chevron], + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-credits-label], + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-usage-label] { + display: none !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-permission-mode-control]:has([data-codexhost-permission-lock]:not([hidden])) [data-codexhost-permission-shield], + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-permission-mode-control]:has([data-codexhost-permission-error]:not([hidden])) [data-codexhost-permission-shield] { + display: none !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-credits-control], + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-usage-control] { + min-width: 28px !important; + max-width: 28px !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-credits-control] .${TRIGGER_CHIP_CLASS}, + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-usage-control] .${TRIGGER_CHIP_CLASS} { + padding-inline: 6px !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-usage-icon] { + display: inline-flex !important; + } + [${COMPACT_COMPOSER_ATTRIBUTE}] [data-codexhost-agent-control] { + margin-inline: 0 !important; + } `; (ownerDocument.head ?? ownerDocument.documentElement).append(style); } diff --git a/packages/renderer-extension/src/renderer-usage-control.ts b/packages/renderer-extension/src/renderer-usage-control.ts index 237c5706b..85843906f 100644 --- a/packages/renderer-extension/src/renderer-usage-control.ts +++ b/packages/renderer-extension/src/renderer-usage-control.ts @@ -1,4 +1,6 @@ import type { ThreadUsageSnapshot } from "@codexhost/shared-contracts"; +import createElement from "lucide/dist/esm/createElement.mjs"; +import Activity from "lucide/dist/esm/icons/activity.mjs"; import type { RendererSettingsLocale } from "./settings/localization.js"; @@ -557,13 +559,20 @@ export function mountRendererUsageControl( trigger.style.fontVariantNumeric = "tabular-nums"; trigger.style.letterSpacing = "0"; + const icon = document.createElement("span"); + icon.dataset.codexhostUsageIcon = ""; + icon.style.display = "none"; + icon.style.alignItems = "center"; + icon.append(createElement(Activity, { width: 16, height: 16, "aria-hidden": "true" })); + const label = document.createElement("span"); + label.dataset.codexhostUsageLabel = ""; label.style.display = "inline-block"; label.style.maxWidth = "100%"; label.style.overflow = "hidden"; label.style.textOverflow = "ellipsis"; label.style.whiteSpace = "nowrap"; - trigger.append(label); + trigger.append(icon, label); const popover = document.createElement("div"); popover.id = `${composerId}-usage-popover`; diff --git a/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts b/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts index 8c2d427b6..d1c959b27 100644 --- a/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts +++ b/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { applyRendererTriggerChipSqueezeRoot, applyRendererTriggerChipSqueezeTrigger, + observeRendererCompactComposer, rendererCreditsTriggerMaxWidth, rendererModelTriggerMaxWidth, rendererPermissionModeTriggerMaxWidth, @@ -26,6 +27,42 @@ function fakeElement(): HTMLElement { } describe("Renderer trigger chip squeeze layout", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("follows the composer width and restores the normal layout on expansion", () => { + let onResize: ResizeObserverCallback | undefined; + const disconnect = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: ResizeObserverCallback) { + onResize = callback; + } + observe = vi.fn(); + disconnect = disconnect; + }, + ); + const attributes = new Set(); + const composer = { + getBoundingClientRect: () => ({ width: 360 }), + toggleAttribute: (name: string, enabled: boolean) => { + if (enabled) attributes.add(name); + else attributes.delete(name); + }, + removeAttribute: (name: string) => attributes.delete(name), + } as unknown as Element; + + const stop = observeRendererCompactComposer(composer); + expect(attributes.has("data-codexhost-compact-composer")).toBe(true); + onResize?.([{ contentRect: { width: 480 } } as ResizeObserverEntry], {} as ResizeObserver); + expect(attributes.has("data-codexhost-compact-composer")).toBe(false); + onResize?.([{ contentRect: { width: 320 } } as ResizeObserverEntry], {} as ResizeObserver); + expect(attributes.has("data-codexhost-compact-composer")).toBe(true); + stop(); + expect(disconnect).toHaveBeenCalledOnce(); + expect(attributes.has("data-codexhost-compact-composer")).toBe(false); + }); + it("exposes max-width budgets that fit a squeezed composer footer", () => { expect(rendererModelTriggerMaxWidth()).toBe("min(200px, 26vw)"); expect(rendererPermissionModeTriggerMaxWidth()).toBe("min(160px, 24vw)"); diff --git a/tests/e2e/renderer-usage.spec.ts b/tests/e2e/renderer-usage.spec.ts index a408a52dc..29a95e565 100644 --- a/tests/e2e/renderer-usage.spec.ts +++ b/tests/e2e/renderer-usage.spec.ts @@ -17,6 +17,7 @@ const { outputFiles } = await build({ mountRendererUsageControl, renderRendererUsageControl, } from "./packages/renderer-extension/src/renderer-usage-control.ts"; + import { observeRendererCompactComposer } from "./packages/renderer-extension/src/renderer-trigger-chip-style.ts"; globalThis.setupRendererUsage = () => { const toolbar = document.createElement("div"); @@ -35,6 +36,7 @@ const { outputFiles } = await build({ model.append(modelButton); toolbar.append(plus, model); document.body.append(toolbar); + globalThis.observeRendererUsageWidth = () => observeRendererCompactComposer(toolbar); const usage = mountRendererUsageControl("usage-composer"); let exactRefreshes = 0; @@ -210,7 +212,7 @@ for (const width of [1280, 375]) { const usage = page.locator('[data-codexhost-usage-control="usage-composer"]'); await expect(usage).toBeVisible(); await expect(usage).toHaveText("0.059 credits"); - await expect(usage.locator("svg")).toHaveCount(0); + await expect(usage.locator("svg")).not.toBeVisible(); await expect(usage.locator("button")).toHaveAttribute( "aria-label", "对话用量: 0.059 credits; 上下文 9.6%", @@ -243,6 +245,45 @@ for (const width of [1280, 375]) { }); } +test("keeps Usage usable while the composer shrinks and expands", async ({ page }) => { + await page.setViewportSize({ width: 800, height: 600 }); + await page.setContent( + '', + ); + await page.addScriptTag({ content: browserBundle }); + await page.evaluate(() => { + Reflect.get(globalThis, "setupRendererUsage")(); + Reflect.get(globalThis, "updateRendererUsage")(); + Reflect.get(globalThis, "observeRendererUsageWidth")(); + }); + const usage = page.locator('[data-codexhost-usage-control="usage-composer"]'); + const label = usage.locator("[data-codexhost-usage-label]"); + const icon = usage.locator("svg"); + const trigger = usage.locator("button"); + await expect(label).toBeVisible(); + await expect(icon).not.toBeVisible(); + + await page.setViewportSize({ width: 380, height: 600 }); + await expect(label).not.toBeVisible(); + await expect(icon).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-label", "Thread Usage: CH 99.9% · $0.822"); + await expect + .poll(() => usage.evaluate((element) => element.getBoundingClientRect().width)) + .toBeLessThanOrEqual(28); + const popover = page.getByRole("dialog", { name: "Thread Usage details" }); + await trigger.hover(); + await expect(popover).toBeVisible(); + await trigger.click(); + await expect(popover).not.toBeVisible(); + await trigger.click(); + await expect(popover).toBeVisible(); + + await page.setViewportSize({ width: 800, height: 600 }); + await expect(label).toBeVisible(); + await expect(label).toHaveText("CH 99.9% · $0.822"); + await expect(icon).not.toBeVisible(); +}); + test("renders Usage immediately to the left of the model control", async ({ page }) => { await page.setContent(''); await page.addScriptTag({ content: browserBundle }); @@ -266,7 +307,7 @@ test("renders Usage immediately to the left of the model control", async ({ page "aria-label", "Thread Usage: CH 99.9% · $0.822", ); - await expect(usage.locator("svg")).toHaveCount(0); + await expect(usage.locator("svg")).not.toBeVisible(); const [usageBox, modelBox] = await Promise.all([usage.boundingBox(), model.boundingBox()]); if (!usageBox || !modelBox) throw new Error("Usage geometry is unavailable"); expect( From 46adadb9971315248e726359834cefa5dad99f0e Mon Sep 17 00:00:00 2001 From: CY2 <40905444+cy2s@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:02:30 +0800 Subject: [PATCH 2/2] fix(renderer): use consistent composer width measurements --- .../src/renderer-trigger-chip-style.ts | 12 +++-- .../test/renderer-trigger-chip-style.test.ts | 43 +++++++++++++++++- tests/e2e/renderer-usage.spec.ts | 44 +++++++++++++++++++ 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/renderer-extension/src/renderer-trigger-chip-style.ts b/packages/renderer-extension/src/renderer-trigger-chip-style.ts index 29eedbf19..3573b98c2 100644 --- a/packages/renderer-extension/src/renderer-trigger-chip-style.ts +++ b/packages/renderer-extension/src/renderer-trigger-chip-style.ts @@ -80,19 +80,17 @@ export function applyRendererTriggerChipSqueezeTrigger(trigger: HTMLElement): vo /** Follow the Composer width, which also changes when a sidebar opens. */ export function observeRendererCompactComposer(composer: Element): () => void { - const update = (width: number): void => { + const update = (): void => { + const width = composer.getBoundingClientRect().width; if (width > 0) { composer.toggleAttribute(COMPACT_COMPOSER_ATTRIBUTE, width <= COMPACT_COMPOSER_WIDTH); } }; - update(composer.getBoundingClientRect().width); + update(); if (typeof ResizeObserver === "undefined") return () => composer.removeAttribute(COMPACT_COMPOSER_ATTRIBUTE); - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) update(entry.contentRect.width); - }); - observer.observe(composer); + const observer = new ResizeObserver(update); + observer.observe(composer, { box: "border-box" }); return () => { observer.disconnect(); composer.removeAttribute(COMPACT_COMPOSER_ATTRIBUTE); diff --git a/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts b/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts index d1c959b27..b23961be4 100644 --- a/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts +++ b/packages/renderer-extension/test/renderer-trigger-chip-style.test.ts @@ -43,8 +43,9 @@ describe("Renderer trigger chip squeeze layout", () => { }, ); const attributes = new Set(); + let width = 360; const composer = { - getBoundingClientRect: () => ({ width: 360 }), + getBoundingClientRect: () => ({ width }), toggleAttribute: (name: string, enabled: boolean) => { if (enabled) attributes.add(name); else attributes.delete(name); @@ -54,8 +55,10 @@ describe("Renderer trigger chip squeeze layout", () => { const stop = observeRendererCompactComposer(composer); expect(attributes.has("data-codexhost-compact-composer")).toBe(true); + width = 480; onResize?.([{ contentRect: { width: 480 } } as ResizeObserverEntry], {} as ResizeObserver); expect(attributes.has("data-codexhost-compact-composer")).toBe(false); + width = 320; onResize?.([{ contentRect: { width: 320 } } as ResizeObserverEntry], {} as ResizeObserver); expect(attributes.has("data-codexhost-compact-composer")).toBe(true); stop(); @@ -63,6 +66,44 @@ describe("Renderer trigger chip squeeze layout", () => { expect(attributes.has("data-codexhost-compact-composer")).toBe(false); }); + it.each([ + { width: 420, contentWidth: 386, compact: true }, + { width: 421, contentWidth: 387, compact: false }, + { width: 400, contentWidth: 500, compact: true }, + ])( + "keeps the initial width decision at $width px when content width differs", + ({ width, contentWidth, compact }) => { + let onResize: ResizeObserverCallback | undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: ResizeObserverCallback) { + onResize = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + }, + ); + const attributes = new Set(); + const composer = { + getBoundingClientRect: () => ({ width }), + toggleAttribute: (name: string, enabled: boolean) => { + if (enabled) attributes.add(name); + else attributes.delete(name); + }, + removeAttribute: (name: string) => attributes.delete(name), + } as unknown as Element; + const stop = observeRendererCompactComposer(composer); + expect(attributes.has("data-codexhost-compact-composer")).toBe(compact); + onResize?.( + [{ target: composer, contentRect: { width: contentWidth } } as ResizeObserverEntry], + {} as ResizeObserver, + ); + expect(attributes.has("data-codexhost-compact-composer")).toBe(compact); + stop(); + }, + ); + it("exposes max-width budgets that fit a squeezed composer footer", () => { expect(rendererModelTriggerMaxWidth()).toBe("min(200px, 26vw)"); expect(rendererPermissionModeTriggerMaxWidth()).toBe("min(160px, 24vw)"); diff --git a/tests/e2e/renderer-usage.spec.ts b/tests/e2e/renderer-usage.spec.ts index 29a95e565..7a064df9b 100644 --- a/tests/e2e/renderer-usage.spec.ts +++ b/tests/e2e/renderer-usage.spec.ts @@ -284,6 +284,50 @@ test("keeps Usage usable while the composer shrinks and expands", async ({ page await expect(icon).not.toBeVisible(); }); +test("uses a consistent padded border box at the compact width boundary", async ({ page }) => { + await page.setViewportSize({ width: 800, height: 600 }); + await page.setContent(''); + await page.addScriptTag({ content: browserBundle }); + const initialCompact = await page.evaluate(() => { + Reflect.get(globalThis, "setupRendererUsage")(); + Reflect.get(globalThis, "updateRendererUsage")(); + const toolbar = document.querySelector("[data-codexhost-usage-control]")?.parentElement; + if (!toolbar) throw new Error("Usage toolbar is unavailable"); + toolbar.style.cssText += ";box-sizing:content-box;width:420px;padding:16px;border:1px solid"; + Reflect.get(globalThis, "observeRendererUsageWidth")(); + return toolbar.hasAttribute("data-codexhost-compact-composer"); + }); + expect(initialCompact).toBe(false); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const usage = page.locator('[data-codexhost-usage-control="usage-composer"]'); + const label = usage.locator("[data-codexhost-usage-label]"); + await expect(label).toBeVisible(); + + await usage.evaluate((element) => { + const toolbar = element.parentElement; + if (!toolbar) throw new Error("Usage toolbar is unavailable"); + toolbar.style.width = "386px"; + }); + await expect(label).not.toBeVisible(); + await usage.evaluate((element) => { + const toolbar = element.parentElement; + if (!toolbar) throw new Error("Usage toolbar is unavailable"); + toolbar.style.width = "387px"; + }); + await expect(label).toBeVisible(); + await usage.evaluate((element) => { + const toolbar = element.parentElement; + if (!toolbar) throw new Error("Usage toolbar is unavailable"); + toolbar.style.paddingInline = "15px"; + }); + await expect(label).not.toBeVisible(); +}); + test("renders Usage immediately to the left of the model control", async ({ page }) => { await page.setContent(''); await page.addScriptTag({ content: browserBundle });