Skip to content
Closed
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
91 changes: 6 additions & 85 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { MiddleTruncate } from "./ui/middle-truncate";
import { ComposerSurface } from "./chat/ComposerSurface";
import { useComposerMenuProps } from "./chat/composerEventScope";
import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
import { measureContextStrip } from "./chat/contextStripMeasurement";
import { cn } from "~/lib/utils";

export interface BranchToolbarHandle {
Expand Down Expand Up @@ -314,31 +313,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({

const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180;
const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]";

/**
* The width a label takes when shown, clipped parts included.
*
* Text keeps its full width when its box clips it, so each text run measures
* whole. A label can hold more than one run (MiddleTruncate splits a branch
* into a head and a tail), so the runs are added. Reading one element's
* scrollWidth drops the tail when the label is hidden or squeezed, and the
* strip then flips between labels and icons on every measure.
*
* A shown label never grows past its motion span's max width, so longer text
* reserves only that much.
*/
function labelTextWidth(label: HTMLElement, range: Range): number {
const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT);
let width = 0;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
range.selectNodeContents(node);
width += range.getBoundingClientRect().width;
}
const motion = label.querySelector<HTMLElement>("[data-composer-label-motion]");
const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN;
return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width;
}

/**
* Collapse the strip's labels to icons only when the text no longer fits.
Expand All @@ -361,69 +335,16 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
const measure = useCallback(() => {
const { element: current, overflows: compact } = stateRef.current;
if (!current) return;
const available = current.clientWidth;
if (available === 0) return;
// flex-1 stretches the groups to fill the strip, so their own boxes always
// measure "full". Sum the laid-out content instead, skipping hidden form
// artifacts and other out-of-flow nodes.
const contentWidth = (parent: Element): number => {
const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0;
let width = 0;
let counted = 0;
for (const child of parent.children) {
if (!(child instanceof HTMLElement)) continue;
if (child.offsetWidth === 0) continue;
const style = getComputedStyle(child);
const position = style.position;
if (position === "absolute" || position === "fixed") continue;
width +=
child.offsetWidth +
(Number.parseFloat(style.marginInlineStart) || 0) +
(Number.parseFloat(style.marginInlineEnd) || 0);
counted += 1;
}
return width + gap * Math.max(0, counted - 1);
};
const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0;
let needed = 0;
let groups = 0;
for (const child of current.children) {
if (!(child instanceof HTMLElement)) continue;
// The host itself flexes into all remaining room. Reserve the natural
// width of the controls inside it, blocks in overflow included, so Git
// labels compact before squeezing out the model picker. Reserving only
// the visible controls would let the labels expand into room the
// composer just freed, shrink the host, and hide the controls again.
const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]')
? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]')
: null;
const hostedMeasurement = hostedControls
? measureRestingComposerControls(hostedControls)
: null;
const width = hostedMeasurement
? resolveRestingComposerControlsNaturalWidth(hostedMeasurement)
: contentWidth(hostedControls ?? child);
if (width <= 1) continue;
groups += 1;
needed += width;
}
needed += stripGap * Math.max(0, groups - 1);
const range = document.createRange();
for (const label of current.querySelectorAll<HTMLElement>("[data-composer-label]")) {
// Subtract the visible width even during an animation. The content
// sum already includes it; only the hidden text needs reserving.
needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width);
}
const measured = measureContextStrip(current);
if (!measured) return;
const nextOverflows = resolveContextStripLabelsCompact({
compact,
neededWidth: needed,
availableWidth: available,
neededWidth: measured.neededWidth,
availableWidth: measured.availableWidth,
});
if (nextOverflows !== compact) {
pendingLabelRectsRef.current = new Map(
Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)).map(
(label) => [label, label.getBoundingClientRect()],
),
measured.labels.map((label) => [label, label.getBoundingClientRect()]),
);
}
setOverflows(nextOverflows);
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ import {
shouldUseRestingComposerLayout,
} from "../composerFooterLayout";
import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";
import { contentInlineWidth } from "../../lib/contentInlineWidth";
import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
import {
ComposerContextActionsContext,
Expand Down Expand Up @@ -1018,11 +1019,7 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null, useContro

const measurement = measureRestingComposerControls(controls);
if (!measurement) return;
const style = getComputedStyle(currentHost);
const hostWidth =
currentHost.clientWidth -
(Number.parseFloat(style.paddingInlineStart) || 0) -
(Number.parseFloat(style.paddingInlineEnd) || 0);
const hostWidth = contentInlineWidth(currentHost);

setLayout((current) => {
const next = resolveRestingComposerControlsLayout({
Expand Down
185 changes: 185 additions & 0 deletions apps/web/src/components/chat/contextStripMeasurement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { measureContextStrip } from "./contextStripMeasurement";

/**
* A strip row with the composer's resting controls docked beside its labels.
*
* Widths are the laid-out ones a real strip produces, so the reservation the
* measurement makes for the hosted controls is read from a host that exists
* rather than asserted against a number.
*/
class FakeElement {
children: FakeElement[] = [];
found = new Map<string, FakeElement[]>();
dataset: Record<string, string> = {};
style: Record<string, string> = {};
textNodes: FakeElement[] = [];
offsetWidth = 0;
clientWidth = 0;
scrollWidth = 0;
rectWidth = 0;
textWidth = 0;
hostSelector: string | null = null;

matches(selector: string): boolean {
return this.hostSelector === selector;
}
querySelector<T>(selector: string): T | null {
return (this.found.get(selector)?.[0] as T | undefined) ?? null;
}
querySelectorAll<T>(selector: string): T[] {
return (this.found.get(selector) ?? []) as T[];
}
getBoundingClientRect(): DOMRect {
return { width: this.rectWidth } as DOMRect;
}
}

/** Picker 52 + block 140 + one gap. The overflow trigger only counts once
* something actually moves into it, so nothing hidden here adds to it. */
function buildControls(): FakeElement {
const picker = new FakeElement();
picker.rectWidth = 52;
picker.style.minWidth = "52px";
// A deliberately collapsed label (`w-0`, `flex-none`) needs no width recovered.
const pickerLabel = new FakeElement();
pickerLabel.clientWidth = 0;
pickerLabel.scrollWidth = 160;
pickerLabel.style.flexGrow = "0";
picker.found.set('[data-chat-provider-model-picker-label="true"]', [pickerLabel]);

const block = new FakeElement();
block.rectWidth = 140;

const overflow = new FakeElement();
overflow.rectWidth = 24;

const controls = new FakeElement();
controls.style.columnGap = "4px";
controls.found.set("[data-chat-provider-model-picker]", [picker]);
controls.found.set("[data-resting-controls-overflow]", [overflow]);
controls.found.set("[data-resting-block]", [block]);
return controls;
}

/** The strip's `ps-1 pe-2` and, after BranchToolbar's override, `gap-1`. */
const STRIP_PADDING_INLINE = 12;
const STRIP_GAP = 4;
const ICON_WIDTH = 16;
const LABEL_TEXT_WIDTH = 100;
const CONTROLS_NATURAL_WIDTH = 196;

/**
* `compact` collapses the label the way the compact styles do: the icon stays,
* the text box shrinks to nothing while its text keeps its natural width.
*/
function buildStrip(compact: boolean): FakeElement {
const labelShown = compact ? 0 : LABEL_TEXT_WIDTH;

const labelText = new FakeElement();
labelText.textWidth = LABEL_TEXT_WIDTH;
const label = new FakeElement();
label.rectWidth = labelShown;
label.textNodes = [labelText];

const trigger = new FakeElement();
trigger.offsetWidth = ICON_WIDTH + labelShown;
trigger.found.set("[data-composer-label]", [label]);

const contextGroup = new FakeElement();
contextGroup.children = [trigger];

const host = new FakeElement();
host.hostSelector = '[data-chat-resting-composer-controls-host="true"]';
host.found.set('[data-chat-composer-resting-controls="true"]', [buildControls()]);

const strip = new FakeElement();
strip.style.paddingInlineStart = "4px";
strip.style.paddingInlineEnd = "8px";
strip.style.columnGap = `${String(STRIP_GAP)}px`;
strip.clientWidth = 724;
strip.children = [contextGroup, host];
strip.found.set("[data-composer-label]", [label]);

return strip;
}

function stubDom(root: FakeElement) {
const styles = new WeakMap<object, Record<string, string>>();
const textWidths = new WeakMap<object, number>();
const textNodes = new WeakMap<object, FakeElement[]>();
const visited = new Set<object>();
const visit = (node: FakeElement) => {
if (visited.has(node)) return;
visited.add(node);
styles.set(node, node.style);
textWidths.set(node, node.textWidth);
textNodes.set(node, node.textNodes);
for (const child of node.children) visit(child);
for (const text of node.textNodes) visit(text);
for (const found of node.found.values()) {
for (const item of found) visit(item);
}
};
visit(root);

class FakeRange {
private node: object | null = null;
selectNodeContents(node: object) {
this.node = node;
}
getBoundingClientRect() {
return { width: textWidths.get(this.node ?? {}) ?? 0 };
}
}

vi.stubGlobal("HTMLElement", FakeElement);
vi.stubGlobal("NodeFilter", { SHOW_TEXT: 4 });
vi.stubGlobal("document", {
createRange: () => new FakeRange(),
createTreeWalker: (root: object) => {
const nodes = textNodes.get(root) ?? [];
let next = 0;
return { nextNode: () => (nodes[next] ? (nodes[next++] as unknown as Node) : null) };
},
});
vi.stubGlobal("getComputedStyle", (element: object) => styles.get(element) ?? {});
}

function measure(compact: boolean) {
const strip = buildStrip(compact);
stubDom(strip);
return measureContextStrip(strip as unknown as HTMLElement)!;
}

afterEach(() => vi.unstubAllGlobals());

describe("measureContextStrip", () => {
it("does not count the strip's own padding as room for its children", () => {
// Counting it opens a band exactly the padding wide where the strip keeps
// its labels while the docked controls have already collapsed to icons.
expect(measure(true).availableWidth).toBe(724 - STRIP_PADDING_INLINE);
});

it("reserves the natural width of the controls, not what they currently show", () => {
// Reserving only the visible controls lets the labels expand into room the
// composer just gave up, which shrinks the host and hides them again.
expect(measure(true).neededWidth).toBe(
ICON_WIDTH + LABEL_TEXT_WIDTH + CONTROLS_NATURAL_WIDTH + STRIP_GAP,
);
});

it("asks for the same width whether the labels show or only their icons do", () => {
// Otherwise the strip reads its own compact state as freed room and
// expands again on the next pass.
expect(measure(true).neededWidth).toBe(measure(false).neededWidth);
});

it("keeps the caller's answer while the row has no layout", () => {
const strip = buildStrip(false);
strip.clientWidth = 0;
stubDom(strip);
expect(measureContextStrip(strip as unknown as HTMLElement)).toBeNull();
});
});
Loading
Loading