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
19 changes: 13 additions & 6 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,21 @@ export function shouldShowComposerContextStrip(input: {
// Hysteresis prevents labels and icons from flapping at the width boundary.
const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16;

export function resolveContextStripLabelsCompact(input: {
compact: boolean;
export type ContextStripCompaction = "none" | "labels" | "branch";

export function resolveContextStripCompaction(input: {
current: ContextStripCompaction;
neededWidth: number;
availableWidth: number;
}): boolean {
return input.compact
? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX
: input.neededWidth > input.availableWidth;
labelsSavedWidth: number;
}): ContextStripCompaction {
const fits = (width: number, expanding: boolean) =>
width <= input.availableWidth - (expanding ? CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX : 0);
if (fits(input.neededWidth, input.current !== "none")) return "none";
if (fits(input.neededWidth - input.labelsSavedWidth, input.current === "branch")) {
return "labels";
}
return "branch";
}

export function resolveEnvModeLabel(mode: EnvMode): string {
Expand Down
55 changes: 39 additions & 16 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import {
ADD_TO_MACHINE_LABEL_CLASS,
type EnvMode,
type EnvironmentOption,
resolveContextStripLabelsCompact,
resolveContextStripCompaction,
type ContextStripCompaction,
resolveCurrentWorkspaceLabel,
resolveEnvModeLabel,
resolveLockedWorkspaceLabel,
Expand Down Expand Up @@ -71,6 +72,11 @@ import {
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { MiddleTruncate } from "./ui/middle-truncate";
import { ComposerSurface } from "./chat/ComposerSurface";
import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement";
import {
resolveRestingComposerControlsMinimumWidth,
resolveRestingComposerControlsNaturalWidth,
} from "./composerFooterLayout";
import { useComposerMenuProps } from "./chat/composerEventScope";
import { cn } from "~/lib/utils";
import { observeResize } from "~/lib/observeResize";
Expand Down Expand Up @@ -707,6 +713,12 @@ const RunContextSelector = memo(function RunContextSelector({
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]";
const RESTING_CONTROLS_SELECTOR = '[data-chat-composer-resting-controls="true"]';

function readStripCompaction(strip: HTMLElement): ContextStripCompaction {
const value = strip.dataset.compact;
return value === "labels" || value === "branch" ? value : "none";
}

/** A hidden or squeezed label's scrollWidth loses its tail, causing label/icon flapping. */
function labelTextWidth(label: HTMLElement, range: Range): number {
Expand Down Expand Up @@ -757,23 +769,23 @@ function animateLabelWidths(
}

/**
* Whether the strip's labels must collapse to icons. The strip is measured when
* How far the strip collapses: labels to icons, then the branch. The strip is measured when
* it, a control group or a hosted control resizes, when its text or children
* change, and when fonts load, which covers everything the widths depend on.
*/
function useLabelsOverflow(): readonly [
function useStripCompaction(): readonly [
attach: (element: HTMLDivElement | null) => (() => void) | undefined,
overflows: boolean,
compaction: ContextStripCompaction,
] {
const [overflows, setOverflows] = useState(false);
const [compaction, setCompaction] = useState<ContextStripCompaction>("none");

const attach = (current: HTMLDivElement | null) => {
if (!current) return;
const labelAnimations = new Map<HTMLElement, Animation>();

const measure = () => {
// The committed attribute, not render state, so a stale closure cannot misread it.
const compact = current.hasAttribute("data-compact");
const compact = readStripCompaction(current);
const stripWidth = current.clientWidth;
if (stripWidth === 0) return;
const stripStyle = getComputedStyle(current);
Expand Down Expand Up @@ -804,28 +816,39 @@ function useLabelsOverflow(): readonly [
let groups = 0;
for (const child of current.children) {
if (!(child instanceof HTMLElement)) continue;
const width = contentWidth(child);
const controls = child.querySelector<HTMLElement>(RESTING_CONTROLS_SELECTOR);
const controlsMeasurement = controls ? measureRestingComposerControls(controls) : null;
if (controlsMeasurement) {
child.style.minWidth = `${resolveRestingComposerControlsMinimumWidth(controlsMeasurement)}px`;
}
const width = controlsMeasurement
? resolveRestingComposerControlsNaturalWidth(controlsMeasurement)
: contentWidth(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]")) {
needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width);
let labelsSavedWidth = 0;
for (const label of current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)) {
const textWidth = labelTextWidth(label, range);
needed += Math.max(0, textWidth - label.getBoundingClientRect().width);
if (label.dataset.composerLabel === "hide") labelsSavedWidth += textWidth;
}
const nextOverflows = resolveContextStripLabelsCompact({
compact,
const nextCompaction = resolveContextStripCompaction({
current: compact,
neededWidth: needed,
availableWidth: available,
labelsSavedWidth,
});
if (nextOverflows === compact) return;
if (nextCompaction === compact) return;
const previousRects = new Map(
Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_LABEL_SELECTOR)).map(
(label) => [label, label.getBoundingClientRect()] as const,
),
);
flushSync(() => setOverflows(nextOverflows));
flushSync(() => setCompaction(nextCompaction));
animateLabelWidths(previousRects, labelAnimations);
};

Expand Down Expand Up @@ -862,7 +885,7 @@ function useLabelsOverflow(): readonly [
};
};

return [attach, overflows] as const;
return [attach, compaction] as const;
}

export const BranchToolbar = memo(function BranchToolbar({
Expand Down Expand Up @@ -959,7 +982,7 @@ export const BranchToolbar = memo(function BranchToolbar({
activeEnvironment: activeEnvironmentOption,
canPickEnvironment: hasMultipleEnvironments,
});
const [attachStrip, labelsOverflow] = useLabelsOverflow();
const [attachStrip, stripCompaction] = useStripCompaction();

if (!activeProject) return null;

Expand Down Expand Up @@ -1045,7 +1068,7 @@ export const BranchToolbar = memo(function BranchToolbar({
return (
<ComposerSurface.ContextStrip
ref={attachStrip}
data-compact={labelsOverflow ? "" : undefined}
data-compact={stripCompaction === "none" ? undefined : stripCompaction}
data-composer-context-control
>
{showMachineChip ? (
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ export function BranchToolbarBranchSelector({
)}
/>
)}
<ComposerContextLabel displayMode={displayMode} compactable={false}>
<ComposerContextLabel displayMode={displayMode} compact="truncate">
{isActiveBranchResolving ? (
<Skeleton className="h-3 w-24" />
) : (
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/components/ComposerContextLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@ import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyle
export function ComposerContextLabel({
children,
displayMode = "toolbar",
compactable = true,
compact = "hide",
}: {
children: ReactNode;
displayMode?: "toolbar" | "panel";
/** False keeps the label (truncated) when the tray row compacts to icons. */
compactable?: boolean;
/** "truncate" keeps a short label until the row compacts its branch too. */
compact?: "hide" | "truncate";
}) {
const compactable = compact === "hide";
return (
<span
data-composer-label
data-composer-label={compact}
className={cn(
"min-w-0",
displayMode === "panel"
? "flex-1 overflow-x-clip overflow-y-visible text-left"
: compactable
? "max-w-[240px] group-data-[compact]/composer-context:max-w-0"
: "max-w-[240px]",
: "max-w-[240px] group-data-[compact=branch]/composer-context:max-w-24",
)}
>
<span
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CSSProperties } from "react";
import { expect, it } from "vite-plus/test";

import { render } from "../../../test/render";
import { measureRestingComposerControls } from "./restingComposerControlsMeasurement";

const row: CSSProperties = { display: "flex", alignItems: "center", columnGap: 4 };
const icon: CSSProperties = { display: "inline-block", width: 16, height: 16, flexShrink: 0 };
const narrowHiddenLabel: CSSProperties = {
position: "absolute",
visibility: "hidden",
width: "max-content",
};

function Controls({ iconOnly }: { iconOnly: boolean }) {
const compact = iconOnly ? "true" : "false";
return (
<div data-testid={`controls-${compact}`} style={{ ...row, columnGap: 2 }}>
<button type="button" data-chat-provider-model-picker style={row}>
<span style={icon} />
<span data-chat-provider-model-picker-label="true" style={narrowHiddenLabel}>
Opus 5.5
</span>
</button>
<div data-resting-fixed-control data-composer-block-icon-only={compact} style={row}>
<span style={icon} />
<span data-composer-control-label style={narrowHiddenLabel}>
Medium
</span>
</div>
<div data-resting-block="mode" data-composer-block-icon-only={compact} style={row}>
<span style={icon} />
<span data-composer-control-label style={narrowHiddenLabel}>
Full access
</span>
</div>
</div>
);
}

it("measures labels a narrow composer hides the same whether or not the controls are icon-only", async () => {
await using screen = await render(
<>
<Controls iconOnly={false} />
<Controls iconOnly />
</>,
);

const measure = (testId: string) => {
const controls = screen.getByTestId(testId).element();
return controls instanceof HTMLElement ? measureRestingComposerControls(controls) : null;
};
const labelled = measure("controls-false");
const iconOnly = measure("controls-true");
expect(labelled).not.toBeNull();
expect(labelled).toEqual(iconOnly);
});
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,13 @@ function controlBlockWidths(
block: HTMLElement,
scale: number,
): { natural: number; iconOnly: number } {
const compact = block.dataset.composerBlockIconOnly === "true";
let natural = elementOuterWidth(block, scale);
let iconOnly = natural;
for (const label of block.querySelectorAll<HTMLElement>("[data-composer-control-label]")) {
const labelStyle = getComputedStyle(label);
const inFlow = labelStyle.position !== "absolute";
// Phone sr-only clipping survives compact styles overriding the label width.
if (!inFlow && (!compact || labelStyle.clip !== "auto")) continue;
if (!inFlow && labelStyle.clip !== "auto") continue;
const labelWidth = label.scrollWidth;
const renderedWidth = inFlow ? label.getBoundingClientRect().width / scale : 0;
const gap = Number.parseFloat(getComputedStyle(label.parentElement!).columnGap) || 0;
Expand Down
37 changes: 32 additions & 5 deletions apps/web/src/components/composerFooterLayout.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vite-plus/test";

import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic";
import { resolveContextStripCompaction } from "./BranchToolbar.logic";
import {
resolveRestingComposerControlsLayout,
resolveRestingComposerControlsMinimumWidth,
resolveRestingComposerControlsNaturalWidth,
} from "./composerFooterLayout";

Expand Down Expand Up @@ -30,17 +31,36 @@ describe("context strip labels and resting composer controls", () => {
});
expect(layout).toEqual({ hiddenCount: 0, visible: true });

const compact = resolveContextStripLabelsCompact({
compact: true,
const compaction = resolveContextStripCompaction({
current: "labels",
neededWidth: chromeWidth + labelWidth + naturalWidth,
availableWidth: stripWidth,
labelsSavedWidth: labelWidth,
});
expect(compact).toBe(true);
expect(compaction).toBe("labels");

expect(
resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }),
resolveRestingComposerControlsLayout({
...measurement,
hostWidth: hostWidth(compaction !== "none"),
}),
).toEqual(layout);
});

it("compacts the branch before the full controls collapse", () => {
const strip = (availableWidth: number, current: "labels" | "branch") =>
resolveContextStripCompaction({
current,
neededWidth: chromeWidth + labelWidth + naturalWidth,
availableWidth,
labelsSavedWidth: labelWidth,
});
const compactLabelsWidth = chromeWidth + naturalWidth;
expect(strip(compactLabelsWidth, "labels")).toBe("labels");
expect(strip(compactLabelsWidth - 1, "labels")).toBe("branch");
expect(strip(compactLabelsWidth + 15, "branch")).toBe("branch");
expect(strip(compactLabelsWidth + 16, "branch")).toBe("labels");
});
});

describe("resolveRestingComposerControlsLayout hysteresis", () => {
Expand Down Expand Up @@ -146,5 +166,12 @@ describe("resolveRestingComposerControlsLayout with icon-only controls", () => {
pickerIconOnly: true,
visible: true,
});
const minimumWidth = resolveRestingComposerControlsMinimumWidth(tray);
expect(resolveRestingComposerControlsLayout({ ...tray, hostWidth: minimumWidth }).visible).toBe(
true,
);
expect(
resolveRestingComposerControlsLayout({ ...tray, hostWidth: minimumWidth - 1 }).visible,
).toBe(false);
});
});
10 changes: 10 additions & 0 deletions apps/web/src/components/composerFooterLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ export function resolveRestingComposerControlsNaturalWidth(
return restingComposerControlsWidth(input, 0);
}

export function resolveRestingComposerControlsMinimumWidth(
input: RestingComposerControlsMeasurement,
): number {
return restingComposerControlsWidth(
input,
input.blockWidths.length,
input.iconOnlyFixedWidth ?? input.minimumFixedWidth,
);
}

const RESTING_CONTROLS_SLACK_PX = 1;

export interface RestingComposerControlsLayout {
Expand Down
Loading