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
8 changes: 6 additions & 2 deletions packages/renderer-extension/src/renderer-composer-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,6 +104,7 @@ export interface ComposerAgentControl {
harnessCommands: RendererHarnessCommandControl;
sendButton: HTMLButtonElement;
sendDisabledBeforeSwitch: boolean | null;
stopCompactLayout?: () => void;
}

export function eventElement(target: EventTarget | null): Element | null {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/renderer-extension/src/renderer-model-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions packages/renderer-extension/src/renderer-trigger-chip-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -76,6 +78,25 @@ 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 = (): void => {
const width = composer.getBoundingClientRect().width;
if (width > 0) {
composer.toggleAttribute(COMPACT_COMPOSER_ATTRIBUTE, width <= COMPACT_COMPOSER_WIDTH);
}
};
update();
if (typeof ResizeObserver === "undefined")
return () => composer.removeAttribute(COMPACT_COMPOSER_ATTRIBUTE);
const observer = new ResizeObserver(update);
observer.observe(composer, { box: "border-box" });
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");
Expand Down Expand Up @@ -114,6 +135,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);
}
11 changes: 10 additions & 1 deletion packages/renderer-extension/src/renderer-usage-control.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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`;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,6 +27,83 @@ 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<string>();
let width = 360;
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(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();
expect(disconnect).toHaveBeenCalledOnce();
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<string>();
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)");
Expand Down
Loading
Loading