Files
paseo/packages/app/src/composer/agent-controls/layout.ts
T
Mohamed Boudra 4b285a113d Space the touch composer toolbar by what it paints (#8909)
* feat(app): tighten the touch composer toolbar onto the text column

On touch, the toolbar's icon-only glyphs were 20pt muted outlines at a 1.5px
stroke in loosely spaced slots, and the row sat on a flat -6 margin, so the
attach + and the trailing control floated inside the text column. Touch now
places 44pt slots edge to edge, draws icon-only glyphs at 24pt with a 2px
foreground stroke, keeps label-paired glyphs at 20pt, and runs the context
ring at 26pt with a 3px stroke. The row overhangs into the composer padding
by each end control's ink inset (composerToolbarOverhang), so the + ink and
the trailing glyph or send/stop disc sit on the text column's edges.
Pointer sizing is unchanged.

The folded model control now drops its thinking label before the model
label truncates, and the model label truncates on a word boundary
("Ten second…") instead of leaving a space before the ellipsis. Both are
decided from measured widths. The web reports layout widths rounded to whole
pixels, so a tie counts as not fitting.

* fix(app): measure replacement model labels instead of hiding them

The folded model label and TailEllipsisText kept their measuring views mounted
when the label text changed, while discarding the cached widths. A retained
measuring node whose size does not change emits no new layout event on web or
native, so after switching to a model whose label (or a shortened prefix of it)
has the same width, the widths stayed unknown and the label stayed transparent.
Keying each measurer by what it measures remounts it, so every width is
measured again.

Also move the label width sweep into a named helper so the test body states
the expected label order.

* feat(app): space the touch composer toolbar by what it paints

- Lay each control out at its painted width, 20pt apart, with a disc-sized circle that is both its frame and its fully rounded pressed highlight. Pickers are 36pt pills.
- Match the header icons: 20pt glyphs at 1.5px, an 18pt context ring at 2px.
- Always end the row in a disc (voice mode, send or stop) so it does not shift, nested into the composer's corner, with a concentric 26pt corner radius on touch.
- Stop the picker row's clip from flattening the picker highlight's left edge.
- Budget the density ladder on laid-out widths.

* fix(app): disable the stand-in send disc and keep form model fields in their box

- An empty new chat's stand-in Send is disabled until there is content, as in fullscreen.
- A custom model selector trigger, such as the schedule form's field, drops the toolbar slot's margins.
2026-11-06 15:56:09 +07:00

293 lines
9.8 KiB
TypeScript

import {
TOOLBAR_CONTROL_GAPS,
TOOLBAR_CONTROL_SIZES,
toolbarGlyphsFor,
type ControlSizing,
type ToolbarGlyphs,
} from "@/components/ui/control-geometry";
/**
* How much of the toolbar shows, widest first. Each step drops something before the next:
* carets and labels, then the mode label, then the thinking, mode and feature controls fold into
* the model control's sheet (`compact`, the phone toolbar), then the model's own label.
*/
export type ComposerControlDensity = "full" | "condensed" | "tight" | "compact" | "iconOnly";
const DENSITY_LADDER: readonly ComposerControlDensity[] = [
"full",
"condensed",
"tight",
"compact",
"iconOnly",
];
export interface ComposerControlPresence {
hasModel: boolean;
hasThinking: boolean;
hasMode: boolean;
features: readonly ComposerFeatureControlPresence[];
fontScale: number;
}
export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string };
export interface ComposerControlPresentation {
showCarets: boolean;
showThinkingLabel: boolean;
showModeLabel: boolean;
aggregateFeatures: boolean;
/** Thinking, mode and features live in the model control's sheet. */
foldControls: boolean;
showModelLabel: boolean;
}
/** What each toolbar control takes up in the row, which is what the density budget sums. */
export interface ComposerToolbarGeometry {
/** An icon-only control's width in the row. */
controlSize: number;
glyphs: ToolbarGlyphs;
controlGap: number;
iconLabelGap: number;
/** A labelled control's padding on each side, as far as it takes room in the row. */
labelPadding: number;
caretSize: number;
}
/**
* The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a
* control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an
* icon-only control takes its glyph's width and a picker's padding takes no room.
*/
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
const glyphs = toolbarGlyphsFor(sizing);
const isTouch = sizing === "touch";
return {
controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer,
glyphs,
controlGap: TOOLBAR_CONTROL_GAPS[sizing],
iconLabelGap: 4,
labelPadding: isTouch ? 0 : 8,
caretSize: 14,
};
}
const DENSITY_HYSTERESIS = 12;
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
}
function sumControlWidths(widths: number[], geometry: ComposerToolbarGeometry): number {
if (widths.length === 0) return 0;
return (
widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * geometry.controlGap
);
}
function estimateLabelWidth(label: string, fontScale: number): number {
return Array.from(label).length * 7 * fontScale;
}
/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */
function labelledChrome(geometry: ComposerToolbarGeometry): number {
return geometry.labelPadding * 2 + geometry.glyphs.label.size + geometry.iconLabelGap;
}
function resolveFeatureControlWidth(
feature: ComposerFeatureControlPresence,
fontScale: number,
geometry: ComposerToolbarGeometry,
): number {
if (feature.type === "toggle") return geometry.controlSize;
return (
geometry.controlSize +
geometry.iconLabelGap +
geometry.labelPadding * 2 +
estimateLabelWidth(feature.label, fontScale)
);
}
function resolveCondensedFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const widths: number[] = [];
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
function resolveFullFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const caret = geometry.caretSize;
const widths: number[] = [];
if (controls.hasModel) widths.push(chrome + caret + 70 * fontScale);
if (controls.hasThinking) widths.push(chrome + geometry.iconLabelGap + caret + 48 * fontScale);
if (controls.hasMode) widths.push(chrome + geometry.iconLabelGap + caret + 96 * fontScale);
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
}
return sumControlWidths(widths, geometry);
}
/** Model at its family label, with thinking, mode and features as icon-only squares. */
function resolveTightFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const widths: number[] = [];
if (controls.hasModel) {
widths.push(labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale);
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
/**
* One model control carrying the model family label. Its thinking label is not budgeted: it is
* the first label to go when the control runs out of room; see `resolveModelTriggerLabels`.
*/
function resolveCompactFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale;
}
function resolveDensityFloor(
density: ComposerControlDensity,
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
switch (density) {
case "full":
return resolveFullFloor(controls, geometry);
case "condensed":
return resolveCondensedFloor(controls, geometry);
case "tight":
return resolveTightFloor(controls, geometry);
case "compact":
return resolveCompactFloor(controls, geometry);
case "iconOnly":
return 0;
}
}
/**
* The widest density whose floor fits `availableWidth`. Widening past the current density needs
* the floor plus the hysteresis and keeping it tolerates the floor minus it, so a width near a
* floor does not flip back and forth. `minimumDensity` is the widest density allowed.
*/
export function resolveComposerControlDensity(input: {
availableWidth: number;
currentDensity: ComposerControlDensity;
controls: ComposerControlPresence;
geometry: ComposerToolbarGeometry;
minimumDensity?: ComposerControlDensity;
}): ComposerControlDensity {
const current = DENSITY_LADDER.indexOf(input.currentDensity);
const first = DENSITY_LADDER.indexOf(input.minimumDensity ?? "full");
for (let step = first; step < DENSITY_LADDER.length; step += 1) {
const density = DENSITY_LADDER[step];
const floor = resolveDensityFloor(density, input.controls, input.geometry);
let required = floor;
if (step < current) required = floor + DENSITY_HYSTERESIS;
if (step === current) required = floor - DENSITY_HYSTERESIS;
if (input.availableWidth >= required) return density;
}
return "iconOnly";
}
const PRESENTATIONS: Record<ComposerControlDensity, ComposerControlPresentation> = {
full: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
foldControls: false,
showModelLabel: true,
},
condensed: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
},
tight: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
},
compact: {
showCarets: false,
showThinkingLabel: true,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: true,
},
iconOnly: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: false,
},
};
export function resolveComposerControlPresentation(
density: ComposerControlDensity,
): ComposerControlPresentation {
return PRESENTATIONS[density];
}
/**
* Which labels the folded model control shows. `measuring` until every width is known, so the
* control never paints a choice made without them.
*/
export type ModelTriggerLabels = "measuring" | "modelAndThinking" | "model";
/** Widths measured from the rendered model control, in points. */
export interface ModelTriggerMeasurements {
/** The width the control may take. */
availableWidth: number;
/** The control with its model and thinking labels side by side, untruncated. */
fullWidth: number;
}
/**
* The thinking label goes first: it shows only while the model label fits beside it in full, so
* the model label truncates only once the thinking label is gone. The web reports layout widths
* rounded to whole pixels, so equal widths can hide a fraction of a pixel of overflow; only a
* full width strictly below the available width is known to fit.
*/
export function resolveModelTriggerLabels(input: {
hasThinking: boolean;
measurements: Partial<ModelTriggerMeasurements>;
}): ModelTriggerLabels {
if (!input.hasThinking) return "model";
const { availableWidth, fullWidth } = input.measurements;
if (availableWidth === undefined || fullWidth === undefined) return "measuring";
return fullWidth < availableWidth ? "modelAndThinking" : "model";
}