diff --git a/docs/design.md b/docs/design.md index e840e81bfa..2d769e4f2d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,7 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als `destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog. -Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. +Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. + +Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover. + +In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size. @@ -140,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset. -The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. +The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`). Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical. diff --git a/packages/app/e2e/browser/composer-control-density.spec.ts b/packages/app/e2e/browser/composer-control-density.spec.ts index ebd9717feb..aad17cd1ae 100644 --- a/packages/app/e2e/browser/composer-control-density.spec.ts +++ b/packages/app/e2e/browser/composer-control-density.spec.ts @@ -1,8 +1,14 @@ import { test, expect } from "../support/fixtures"; import { openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { fillComposerDraft } from "../support/helpers/composer"; import { + expectComposerToolbarOnTextRails, expectNoCollapsedComposerToolbarFrame, + expectNoFrameTruncatesModelBesideThinking, + readSettledModelTriggerLabels, recordComposerToolbarFrames, + recordModelTriggerFramesFromLoad, + sweepModelTriggerLabels, } from "../support/helpers/composer-control-density"; import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -124,3 +130,128 @@ test.describe("Composer controls folding on a touch device", () => { } }); }); + +test.describe("Folded model control on a phone", () => { + // Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit. + test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true }); + test.describe.configure({ timeout: 180_000 }); + + test("hides the thinking label before the model label truncates", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "composer-density-labels-", + title: "Label priority chat", + model: "ten-second-stream", + }); + try { + await recordModelTriggerFramesFromLoad(page); + await openAgentRoute(page, session); + await expect( + page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }), + ).toHaveText("Low"); + await readSettledModelTriggerLabels(page); + // From the first frame on, the control never guesses: it paints both labels only once it + // has measured that they fit. + await expectNoFrameTruncatesModelBesideThinking(page); + + const sweep = await sweepModelTriggerLabels(page, { + from: 480, + to: 280, + step: 4, + height: 844, + }); + + expect(sweep.widest).toMatchObject({ + modelText: "Ten second stream", + thinkingShown: true, + modelTruncated: false, + }); + expect(sweep.thinkingHidden).toMatchObject({ + modelText: "Ten second stream", + modelTruncated: false, + }); + expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width); + // A clean tail: the label ends on a whole word and the ellipsis, never on a space. + expect(sweep.modelTruncated).toMatchObject({ + modelText: "Ten second…", + thinkingShown: false, + }); + } finally { + await session.cleanup(); + } + }); +}); + +test.describe("Composer toolbar rails on touch", () => { + test.use({ isMobile: true, hasTouch: true }); + test.describe.configure({ timeout: 180_000 }); + + for (const viewport of [ + { device: "phone", width: 390, height: 844 }, + { device: "tablet", width: 1024, height: 1366 }, + ]) { + test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({ + page, + }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + const idle = await seedMockAgentWorkspace({ + repoPrefix: "composer-rails-idle-", + title: "Rails idle chat", + }); + let running: Awaited> | null = null; + try { + await openAgentRoute(page, idle); + await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({ + timeout: 30_000, + }); + await test.step("empty composer: attach on the left, voice mode on the right", async () => { + await expectComposerToolbarOnTextRails(page); + }); + await test.step("draft: the send disc on the right", async () => { + await fillComposerDraft(page, "Refactor the composer toolbar"); + await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible(); + await expectComposerToolbarOnTextRails(page); + }); + + running = await seedMockAgentWorkspace({ + repoPrefix: "composer-rails-running-", + title: "Rails running chat", + model: "one-minute-stream", + initialPrompt: "stream for a while", + }); + await openAgentRoute(page, running); + await test.step("running agent: the stop disc on the right", async () => { + await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({ + timeout: 30_000, + }); + await expectComposerToolbarOnTextRails(page); + }); + } finally { + await idle.cleanup(); + await running?.cleanup(); + } + }); + } +}); + +test.describe("Composer trailing disc on a new chat", () => { + test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } }); + + test("an empty draft holds a disabled send disc until there is something to send", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" }); + try { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + const send = page + .getByTestId("message-input-root") + .filter({ visible: true }) + .getByRole("button", { name: "Send message", exact: true }); + await expect(send).toBeDisabled({ timeout: 30_000 }); + await fillComposerDraft(page, "Refactor the composer toolbar"); + await expect(send).toBeEnabled(); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/composer-control-density.ts b/packages/app/e2e/support/helpers/composer-control-density.ts index ff5e53b25c..b8ebc22ab6 100644 --- a/packages/app/e2e/support/helpers/composer-control-density.ts +++ b/packages/app/e2e/support/helpers/composer-control-density.ts @@ -53,3 +53,192 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise const collapsed = frames.filter((frame) => frame.labels.some((label) => label === "")); expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]); } + +/** What the folded model control's labels show, read from the DOM. */ +export interface ModelTriggerLabelState { + thinkingShown: boolean; + modelShown: boolean; + modelText: string; + modelTruncated: boolean; +} + +declare global { + interface Window { + __modelTriggerFrames?: ModelTriggerLabelState[]; + } +} + +function readModelTriggerLabelStateInPage(): ModelTriggerLabelState { + const visible = (node: Element) => node.getClientRects().length > 0; + const model = Array.from( + document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'), + ).find(visible); + const thinking = Array.from( + document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'), + ).find(visible); + const shown = (node: Element | null): boolean => { + for (let current = node; current; current = current.parentElement) { + if (Number(getComputedStyle(current).opacity) === 0) return false; + } + return true; + }; + const modelShown = model !== undefined && shown(model); + const modelText = model?.textContent ?? ""; + return { + modelShown, + modelText, + thinkingShown: modelShown && thinking !== undefined, + modelTruncated: + model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth), + }; +} + +/** Records the folded model control's labels on every painted frame from page load. */ +export async function recordModelTriggerFramesFromLoad(page: Page): Promise { + await page.addInitScript(`(() => { + const read = ${readModelTriggerLabelStateInPage.toString()}; + const frames = []; + window.__modelTriggerFrames = frames; + const tick = () => { + frames.push(read()); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + })()`); +} + +/** The model control's labels once two reads 100ms apart agree. */ +export async function readSettledModelTriggerLabels(page: Page): Promise { + let previous = await page.evaluate(readModelTriggerLabelStateInPage); + for (;;) { + await page.waitForTimeout(100); + const next = await page.evaluate(readModelTriggerLabelStateInPage); + if (JSON.stringify(next) === JSON.stringify(previous)) return next; + previous = next; + } +} + +/** The labels at the widths where the folded model control changes what it shows. */ +export interface ModelTriggerLabelSweep { + /** The labels at the widest width. */ + widest: ModelTriggerLabelState & { width: number }; + /** The first width at which the thinking label is hidden. */ + thinkingHidden: ModelTriggerLabelState & { width: number }; + /** The first width at which the model label is truncated. */ + modelTruncated: ModelTriggerLabelState & { width: number }; +} + +/** + * Narrows the viewport step by step and records where the thinking label disappears and where the + * model label first truncates. + */ +export async function sweepModelTriggerLabels( + page: Page, + widths: { from: number; to: number; step: number; height: number }, +): Promise { + const steps: (ModelTriggerLabelState & { width: number })[] = []; + for (let width = widths.from; width >= widths.to; width -= widths.step) { + await page.setViewportSize({ width, height: widths.height }); + const labels = await readSettledModelTriggerLabels(page); + steps.push({ ...labels, width }); + if (labels.modelTruncated || !labels.modelShown) break; + } + const thinkingHidden = steps.find((step) => !step.thinkingShown); + const modelTruncated = steps.find((step) => step.modelTruncated); + if (!thinkingHidden || !modelTruncated) { + throw new Error(`the sweep never hid the thinking label and truncated the model label`); + } + return { widest: steps[0], thinkingHidden, modelTruncated }; +} + +/** No frame recorded so far painted a truncated model label beside the thinking label. */ +export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise { + const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []); + expect(frames.some((frame) => frame.modelShown)).toBe(true); + const wrong = frames + .map((frame, index) => ({ ...frame, index })) + .filter((frame) => frame.thinkingShown && frame.modelTruncated); + expect(wrong, `of ${frames.length} frames`).toEqual([]); +} + +/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */ +export interface ComposerToolbarRails { + /** Where the text column starts. */ + textLeft: number; + /** Where the toolbar's leftmost ink starts. */ + inkLeft: number; + /** How far the trailing disc sits from the composer's right edge. */ + discRightInset: number; + /** How far the trailing disc sits from the composer's bottom edge. */ + discBottomInset: number; +} + +/** + * Reads where the toolbar's ink starts against the text column and where the trailing disc sits + * in the composer's corner. A control's ink is its filled disc when it has one, otherwise its + * glyph's stroked geometry. + */ +export async function readComposerToolbarRails(page: Page): Promise { + const root = page.locator('[data-testid="message-input-root"]:visible').first(); + return root.evaluate((element) => { + const textarea = element.querySelector("textarea"); + if (!textarea) throw new Error("composer has no text input"); + const composerRect = element.getBoundingClientRect(); + const textLeft = + textarea.getBoundingClientRect().left + + Number.parseFloat(getComputedStyle(textarea).paddingLeft); + + const isFilled = (node: Element) => { + const color = getComputedStyle(node).backgroundColor; + return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color); + }; + const glyphInkLeft = (svg: SVGSVGElement) => { + let left = Number.POSITIVE_INFINITY; + for (const shape of Array.from( + svg.querySelectorAll("path, circle, rect, line, polyline"), + )) { + const matrix = shape.getScreenCTM(); + if (!matrix) continue; + const box = shape.getBBox(); + const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0"); + left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e); + } + return left; + }; + + const band = composerRect.bottom - 64; + let inkLeft = Number.POSITIVE_INFINITY; + let trailingDisc: DOMRect | null = null; + for (const control of Array.from(element.querySelectorAll('[role="button"]'))) { + const rect = control.getBoundingClientRect(); + if (rect.width === 0 || rect.top < band) continue; + const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled); + const svg = control.querySelector("svg"); + if (disc) { + const discRect = disc.getBoundingClientRect(); + inkLeft = Math.min(inkLeft, discRect.left); + if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect; + } else if (svg) { + inkLeft = Math.min(inkLeft, glyphInkLeft(svg)); + } + } + if (!trailingDisc) throw new Error("composer toolbar has no trailing disc"); + return { + textLeft, + inkLeft, + discRightInset: composerRect.right - trailingDisc.right, + discBottomInset: composerRect.bottom - trailingDisc.bottom, + }; + }); +} + +/** + * The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the + * corner, as far from the right edge as from the bottom edge. Within half a pixel. + */ +export async function expectComposerToolbarOnTextRails(page: Page): Promise { + const rails = await readComposerToolbarRails(page); + // A precision of 0 digits passes within half a pixel. + expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0); + expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0); +} diff --git a/packages/app/src/agent-controls/icons.ts b/packages/app/src/agent-controls/icons.ts index 06b2759071..8effc9831d 100644 --- a/packages/app/src/agent-controls/icons.ts +++ b/packages/app/src/agent-controls/icons.ts @@ -18,6 +18,7 @@ import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol export interface AgentControlIconProps { size: number; color: string; + strokeWidth?: number; } export type AgentControlIcon = ComponentType; diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 2f6ab0137e..cfd33227a2 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -13,7 +13,11 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { + TOOLBAR_GLYPH_SIZES, + toolbarContentSlot, + type ToolbarGlyph, +} from "@/components/ui/control-geometry"; const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -61,7 +65,7 @@ interface CombinedModelSelectorProps { */ triggerFill?: boolean; toolbar?: { - glyphSize: number; + glyph: ToolbarGlyph; showCaret: boolean; }; } @@ -253,11 +257,13 @@ export function CombinedModelSelector({ chevron={toolbar?.showCaret === false ? null : undefined} > {selectedProvider.trim().length > 0 ? ( - + ) : null} @@ -293,31 +299,30 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: 0, }, trigger: { - height: TOOLBAR_CONTROL_HEIGHT, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", backgroundColor: "transparent", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, }, triggerHovered: { backgroundColor: theme.colors.surface2, }, - toolbarGlyph16: { - width: 16, - height: 16, + glyph: { + width: ICON_SIZE.md, + height: ICON_SIZE.md, flexShrink: 0, }, - toolbarGlyph20: { - width: 20, - height: 20, + toolbarGlyph: { + width: TOOLBAR_GLYPH_SIZES.label, + height: TOOLBAR_GLYPH_SIZES.label, flexShrink: 0, }, triggerPressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, triggerDisabled: { opacity: 0.5, @@ -329,9 +334,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, + // A custom trigger, such as a form field, owns its own box: none of the toolbar slot applies. customTriggerWrapper: { paddingHorizontal: 0, paddingVertical: 0, + marginHorizontal: 0, height: "auto", }, triggerFill: { diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 4d78ea7592..e0ed5b22a8 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -1,5 +1,11 @@ import { useCallback, useMemo, useState } from "react"; -import { Pressable, Text, View, useWindowDimensions } from "react-native"; +import { + Pressable, + type PressableStateCallbackType, + Text, + View, + useWindowDimensions, +} from "react-native"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -9,7 +15,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; import { useHostReportsUsage } from "@/usage"; import type { Theme } from "@/styles/theme"; -import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { TOOLBAR_DISC_SIZE, type ToolbarRing } from "@/components/ui/control-geometry"; import { ContextWindowDetails } from "./context-window-details"; import { ContextWindowSheet } from "./context-window-sheet"; import { formatContextPercentage } from "./context-window-meter.utils"; @@ -21,8 +27,8 @@ interface ContextWindowMeterProps { usedTokens: number | null; totalCostUsd?: number | null; showPercentage?: boolean; - /** Optional glyph envelope for icon-toolbar alignment. */ - glyphSize?: number; + /** The ring's box and stroke when it sits in an icon toolbar. */ + toolbarRing?: ToolbarRing; } const SVG_SIZE = 14; @@ -71,7 +77,7 @@ function getProgressColor(percentage: number, theme: Theme): string { return theme.colors.foregroundMuted; } -function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { +function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) { if (showPercentage) { return { svgSize: COMPACT_SVG_SIZE, @@ -80,8 +86,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) { containerStyle: styles.containerWithLabel, }; } - const resolvedSize = glyphSize ?? SVG_SIZE; - const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH; + const resolvedSize = ring?.size ?? SVG_SIZE; + const resolvedStrokeWidth = ring?.strokeWidth ?? STROKE_WIDTH; return { svgSize: resolvedSize, radius: (resolvedSize - resolvedStrokeWidth) / 2, @@ -151,7 +157,7 @@ export function ContextWindowMeter({ usedTokens, totalCostUsd, showPercentage = false, - glyphSize, + toolbarRing, }: ContextWindowMeterProps) { const { t, i18n } = useTranslation(); const { width } = useWindowDimensions(); @@ -165,7 +171,7 @@ export function ContextWindowMeter({ const closeSheet = useCallback(() => setIsSheetOpen(false), []); const percentage = maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null; - const geometry = getMeterGeometry(showPercentage, glyphSize); + const geometry = getMeterGeometry(showPercentage, toolbarRing); const context = useMemo( () => @@ -184,6 +190,10 @@ export function ContextWindowMeter({ const formattedSessionCost = typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null; const containerStyle = geometry.containerStyle; + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [containerStyle, pressed && styles.pressed], + [containerStyle], + ); const ring = ( ({ container: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, containerWithLabel: { - height: TOOLBAR_CONTROL_HEIGHT, + height: TOOLBAR_DISC_SIZE, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], borderRadius: theme.borderRadius.full, }, + pressed: { + backgroundColor: theme.colors.surface2, + }, percentageLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index ec1a26a1d1..82edd1458d 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -216,17 +216,19 @@ export function ModelProviderGlyph({ provider, serverId, size, + strokeWidth, tone = "muted", }: { provider: string; serverId: string | null; size: number; + strokeWidth?: number; tone?: ProviderGlyphTone; }) { const Icon = useProviderIcon(provider, serverId); const color = tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color; - return ; + return ; } function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index d8548cbd16..995c0876da 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -20,6 +20,8 @@ import { resolveProviderIconName } from "@/components/provider-icon-name"; export interface ProviderIconProps { size: number; color: string; + /** Honoured by outline glyphs such as the fallback; brand marks are filled and ignore it. */ + strokeWidth?: number; } export type ProviderIconComponent = ComponentType; diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 9b0ef21308..431bb0434b 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -12,10 +12,19 @@ import { resolveControlSizing, switchGeometryForLayout, getControlInteractionPhase, + TOOLBAR_CONTROL_GAP, + TOOLBAR_CONTROL_GAPS, TOOLBAR_CONTROL_HEIGHT, TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_GLYPH_SIZES, TOOLBAR_DISC_SIZE, TOOLBAR_DISC_SIZES, + TOOLBAR_GLYPH_SIZES, + TOOLBAR_RINGS, + toolbarGlyphColor, + toolbarGlyphsFor, + toolbarGlyphTone, + toolbarSlot, TOUCH_TARGET, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; @@ -109,6 +118,62 @@ describe("control sizing gate", () => { }); }); +describe("toolbar glyphs and spacing", () => { + it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => { + expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 }); + expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 }); + // A 20pt glyph's disc-sized frame reaches 8 into the gap on each side. + expect(toolbarSlot(20)).toEqual({ + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + marginHorizontal: { xs: -8, md: 0 }, + }); + }); + + it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => { + const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" }; + expect(toolbarGlyphsFor("touch").icon).toEqual({ + size: 20, + strokeWidth: 1.5, + tone: "foreground", + }); + expect(toolbarGlyphsFor("pointer")).toEqual({ icon: pointerGlyph, label: pointerGlyph }); + expect(TOOLBAR_GLYPH_SIZES.icon).toEqual({ xs: 20, md: 16 }); + expect(toolbarGlyphsFor("touch").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.touch); + expect(toolbarGlyphsFor("pointer").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.pointer); + // A disc's solid glyph stays at the `md` size so the stop square does not fill its disc. + expect(TOOLBAR_DISC_GLYPH_SIZES).toEqual({ touch: 20, pointer: 16 }); + }); + + it("keeps a touch glyph beside a label at the label's 20pt scale", () => { + expect(toolbarGlyphsFor("touch").label).toEqual({ + size: 20, + strokeWidth: 1.5, + tone: "foreground", + }); + expect(TOOLBAR_GLYPH_SIZES.label).toEqual({ xs: 20, md: 16 }); + }); + + it("sizes the touch context ring to the toolbar glyph's ink and keeps the pointer ring", () => { + expect(TOOLBAR_RINGS).toEqual({ + touch: { size: 18, strokeWidth: 2 }, + pointer: { size: 16, strokeWidth: 2 }, + }); + }); + + it("brightens a muted toolbar glyph under a hovering pointer", () => { + const pointer = toolbarGlyphsFor("pointer").icon; + const touch = toolbarGlyphsFor("touch").icon; + expect(toolbarGlyphTone(pointer, false)).toBe("muted"); + expect(toolbarGlyphTone(pointer, true)).toBe("foreground"); + expect(toolbarGlyphTone(touch, false)).toBe("foreground"); + const colors = { foreground: "#fafafa", foregroundMuted: "#a1a5a4" }; + const palette = { colors } as unknown as Theme; + expect(toolbarGlyphColor(palette, "foreground")).toBe("#fafafa"); + expect(toolbarGlyphColor(palette, "muted")).toBe("#a1a5a4"); + }); +}); + describe("control geometry", () => { it("keeps resting control borders transparent while preserving border geometry", () => { const geometry = createControlGeometry(theme); diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index ae552a4c78..0ec75ad6ab 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -1,5 +1,5 @@ import type { StyleProp, ViewStyle } from "react-native"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { ICON_SIZE, ICON_STROKE_WIDTH, type Theme } from "@/styles/theme"; import { bySizing, controlSizingForLayout, @@ -87,20 +87,63 @@ export const ICON_BUTTON_SIZES = { } satisfies Record<"large" | "small", SizingValue>; /** - * A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled - * pill. Touch-sized it is one touch target. + * The height of a dense toolbar row, such as the composer's. Touch-sized it is one touch target; + * its controls are disc-sized and centre in it. */ export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue; export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES); /** - * The painted disc of a filled toolbar control, such as the composer's send and stop. The - * control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so - * touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame. + * The disc of a filled toolbar control, such as the composer's send and stop, and the frame and + * highlight of every other toolbar control (`toolbarSlot`). Pointer-sized it fills the row. */ export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue; export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES); +/** + * The space between adjacent toolbar controls. Touch-sized it is the visible space between what + * the controls paint, because each control lays out at its painted width (`toolbarSlot`). + */ +export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue; +export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS); + +/** + * A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight. + * Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches + * into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square + * control. + */ +export function toolbarSlot(shapeWidth: number): { + width: LayoutValue; + height: LayoutValue; + marginHorizontal: LayoutValue; +} { + return { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + marginHorizontal: bySizing({ + touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2, + pointer: 0, + }), + }; +} + +/** + * A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized + * its padding reaches into the gaps, so its content is what the row spaces. + */ +export function toolbarContentSlot(padding: number): { + height: LayoutValue; + paddingHorizontal: number; + marginHorizontal: LayoutValue; +} { + return { + height: TOOLBAR_DISC_SIZE, + paddingHorizontal: padding, + marginHorizontal: bySizing({ touch: -padding, pointer: 0 }), + }; +} + /** * Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body * text, so each step is larger. @@ -112,6 +155,81 @@ export function iconSizeFor(size: IconSize): SizingValue { return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] }; } +/** + * The glyph a toolbar control paints. Touch-sized every toolbar glyph matches the header icons: + * 20pt at the app's 1.5px stroke, resting at `foreground`. Pointer-sized it is the `md` glyph at + * the same stroke and rests muted until hovered. + */ +export type ToolbarGlyphTone = "foreground" | "muted"; + +export interface ToolbarGlyph { + size: number; + strokeWidth: number; + /** The tone at rest; a hovering pointer brightens it to `foreground`. */ + tone: ToolbarGlyphTone; +} + +/** `icon` is an icon-only control's glyph; `label` is a glyph paired with a label. */ +export interface ToolbarGlyphs { + icon: ToolbarGlyph; + label: ToolbarGlyph; +} + +const POINTER_TOOLBAR_GLYPH: ToolbarGlyph = { + size: ICON_SIZE.md, + strokeWidth: ICON_STROKE_WIDTH, + tone: "muted", +}; + +const TOOLBAR_GLYPHS: Record = { + touch: { + icon: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" }, + label: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" }, + }, + pointer: { icon: POINTER_TOOLBAR_GLYPH, label: POINTER_TOOLBAR_GLYPH }, +}; + +export function toolbarGlyphsFor(sizing: ControlSizing): ToolbarGlyphs { + return TOOLBAR_GLYPHS[sizing]; +} + +/** The slot each toolbar glyph sits in, as stylesheet values. */ +export const TOOLBAR_GLYPH_SIZES = { + icon: bySizing({ touch: TOOLBAR_GLYPHS.touch.icon.size, pointer: POINTER_TOOLBAR_GLYPH.size }), + label: bySizing({ touch: TOOLBAR_GLYPHS.touch.label.size, pointer: POINTER_TOOLBAR_GLYPH.size }), +} satisfies Record; + +/** + * The context ring in a toolbar row. A full circle fills its box while an outline glyph's ink + * sits inset, so touch-sized the ring is 18pt, the height of a 20pt glyph's ink. Its 2px stroke + * is half a pixel over the glyphs' so the thin circle does not read lighter than them. + * Pointer-sized it is the `md` glyph. + */ +export interface ToolbarRing { + size: number; + strokeWidth: number; +} + +export const TOOLBAR_RINGS = { + touch: { size: 18, strokeWidth: 2 }, + pointer: { size: ICON_SIZE.md, strokeWidth: 2 }, +} satisfies Record; + +export function toolbarGlyphTone(glyph: ToolbarGlyph, hovered: boolean): ToolbarGlyphTone { + return hovered ? "foreground" : glyph.tone; +} + +export function toolbarGlyphColor(theme: Theme, tone: ToolbarGlyphTone): string { + return tone === "foreground" ? theme.colors.foreground : theme.colors.foregroundMuted; +} + +/** + * The solid glyph inside a toolbar disc, such as the stop square. It stays at the `md` glyph + * size: at the toolbar glyph size the square would fill half its disc. Send's outline arrow + * takes the toolbar glyph. + */ +export const TOOLBAR_DISC_GLYPH_SIZES = iconSizeFor("md"); + /** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */ export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number { return CONTROL_SIZES[size][controlSizingForLayout(isCompact)]; diff --git a/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx b/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx new file mode 100644 index 0000000000..fd0706d4fd --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/index.browser.test.tsx @@ -0,0 +1,61 @@ +import React, { act, useMemo } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { View } from "react-native"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { TailEllipsisText } from "./index"; + +// App sources compile against the classic JSX runtime, which expects React on the global. +beforeEach(() => vi.stubGlobal("React", React)); + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + host.remove(); +}); + +const TEXT_STYLE = { fontSize: 16, fontFamily: "monospace" } as const; + +function LabelRow({ text, width }: { text: string; width: number }) { + const rowStyle = useMemo(() => ({ width, flexDirection: "row" as const }), [width]); + return ( + + + + ); +} + +function renderLabel(text: string, width: number): void { + act(() => root.render()); +} + +function shownLabel(): { text: string; visible: boolean } { + const label = host.querySelector('[data-testid="label"]'); + if (!label) throw new Error("label did not render"); + return { text: label.textContent ?? "", visible: getComputedStyle(label).opacity !== "0" }; +} + +it("shows a replacement label that shares its shortened prefixes with the previous one", async () => { + // Wide enough for "Ten second…" in monospace, too narrow for either full label. + const width = 12 * 10; + renderLabel("Ten second stream", width); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true })); + + renderLabel("Ten second thread", width); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true })); +}); + +it("shows a replacement label as wide as the previous one", async () => { + renderLabel("Opus 4.7", 400); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.7", visible: true })); + + renderLabel("Opus 4.8", 400); + await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.8", visible: true })); +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/index.tsx b/packages/app/src/components/ui/tail-ellipsis-text/index.tsx new file mode 100644 index 0000000000..d8d477def1 --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/index.tsx @@ -0,0 +1,145 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, View, type LayoutChangeEvent, type StyleProp, type TextStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { readMeasuredWidth } from "@/hooks/use-container-width"; +import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve"; + +interface MeasuredWidths { + text: string; + full?: number; + candidates: (number | undefined)[]; +} + +/** + * One line of text that shrinks with its row and, when it does not fit, ends on a whole word + * and an ellipsis ("Ten second…"), never on a space. It measures itself, so it stays invisible + * for the frame before its widths are known. + */ +export function TailEllipsisText({ + text, + style, + testID, +}: { + text: string; + /** Text styles; the component owns its layout. */ + style: StyleProp; + testID?: string; +}) { + const candidates = useMemo(() => tailEllipsisCandidates(text), [text]); + const [boxWidth, setBoxWidth] = useState(undefined); + const [widths, setWidths] = useState({ text, candidates: [] }); + const current = widths.text === text ? widths : { text, candidates: [] }; + + const onBoxLayout = useCallback((event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setBoxWidth(width); + }, []); + const measure = useCallback( + (slot: "full" | number) => (event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width === null) return; + setWidths((previous) => { + const base = previous.text === text ? previous : { text, candidates: [] }; + if (slot === "full") return { ...base, full: width }; + const next = [...base.candidates]; + next[slot] = width; + return { ...base, candidates: next }; + }); + }, + [text], + ); + + const decision = resolveTailEllipsis({ + candidates, + measurements: { boxWidth, fullWidth: current.full, candidateWidths: current.candidates }, + }); + const shownText = decision.kind === "truncated" ? decision.text : text; + + return ( + + {/* Sizes the box: as wide as the text, or as the row allows. */} + + {text} + + + + {shownText} + + + {/* Keyed by the text, so new text remounts the measurer and every width is measured again. + A retained node whose size does not change emits no new layout event. */} + + + {text} + + {candidates.map((candidate, index) => ( + + {candidate} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + // A row, so the absolute measurer below lays its text out at full width on native too. + box: { + minWidth: 0, + flexShrink: 1, + flexDirection: "row", + }, + sizer: { + minWidth: 0, + flexShrink: 1, + opacity: 0, + }, + shown: { + position: "absolute", + left: 0, + right: 0, + top: 0, + bottom: 0, + flexDirection: "row", + alignItems: "center", + }, + shownText: { + minWidth: 0, + flexShrink: 1, + }, + measuring: { + opacity: 0, + }, + measurer: { + position: "absolute", + left: 0, + top: 0, + flexDirection: "row", + alignItems: "flex-start", + opacity: 0, + }, + measured: { + flexShrink: 0, + }, +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts b/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts new file mode 100644 index 0000000000..e65ae7e4a2 --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/resolve.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve"; + +const candidates = tailEllipsisCandidates("Ten second stream"); +const candidateWidths = [34, 80]; + +describe("tail ellipsis", () => { + it("cuts at word boundaries, so no whitespace sits before the ellipsis", () => { + expect(candidates).toEqual(["Ten…", "Ten second…"]); + expect(tailEllipsisCandidates(" GPT-5.1 Codex ")).toEqual(["GPT-5.1…"]); + expect(tailEllipsisCandidates("claude-opus-4-5")).toEqual([]); + }); + + it("shows the full text while it fits", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 132, fullWidth: 132.3, candidateWidths }, + }), + ).toEqual({ kind: "full" }); + }); + + it("keeps the most words that fit with the ellipsis", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 98, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "truncated", text: "Ten second…" }); + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 60, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "truncated", text: "Ten…" }); + }); + + it("lets the platform cut inside a first word too long for the box", () => { + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 20, fullWidth: 132, candidateWidths }, + }), + ).toEqual({ kind: "clipped" }); + expect( + resolveTailEllipsis({ + candidates: [], + measurements: { boxWidth: 60, fullWidth: 132, candidateWidths: [] }, + }), + ).toEqual({ kind: "clipped" }); + }); + + it("decides nothing until the widths it needs are measured", () => { + expect(resolveTailEllipsis({ candidates, measurements: { candidateWidths } })).toEqual({ + kind: "measuring", + }); + expect( + resolveTailEllipsis({ + candidates, + measurements: { boxWidth: 98, fullWidth: 132, candidateWidths: [34] }, + }), + ).toEqual({ kind: "measuring" }); + }); +}); diff --git a/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts b/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts new file mode 100644 index 0000000000..1e7bb61d1c --- /dev/null +++ b/packages/app/src/components/ui/tail-ellipsis-text/resolve.ts @@ -0,0 +1,47 @@ +const ELLIPSIS = "…"; + +/** + * The word-boundary truncations of `text`, shortest first: "Ten second stream" gives "Ten…" and + * "Ten second…". Cutting at a word keeps whitespace out from before the ellipsis. + */ +export function tailEllipsisCandidates(text: string): string[] { + const words = text.trim().split(/\s+/); + return words.slice(1).map((_, index) => `${words.slice(0, index + 1).join(" ")}${ELLIPSIS}`); +} + +export type TailEllipsis = + | { kind: "measuring" } + | { kind: "full" } + | { kind: "truncated"; text: string } + /** Not even the first word fits: the platform ellipsis cuts inside it, where no space can sit. */ + | { kind: "clipped" }; + +/** Widths in points, measured from the rendered text. */ +export interface TailEllipsisMeasurements { + /** The space the text may take, up to its full width. */ + boxWidth?: number; + fullWidth?: number; + /** One per `tailEllipsisCandidates` entry, in the same order. */ + candidateWidths: readonly (number | undefined)[]; +} + +/** Sub-point slack for widths the layout rounds differently from the text it measures. */ +const ROUNDING_SLACK = 0.5; + +export function resolveTailEllipsis(input: { + candidates: readonly string[]; + measurements: TailEllipsisMeasurements; +}): TailEllipsis { + const { boxWidth, fullWidth, candidateWidths } = input.measurements; + if (boxWidth === undefined || fullWidth === undefined) return { kind: "measuring" }; + if (fullWidth <= boxWidth + ROUNDING_SLACK) return { kind: "full" }; + if (input.candidates.some((_, index) => candidateWidths[index] === undefined)) { + return { kind: "measuring" }; + } + for (let index = input.candidates.length - 1; index >= 0; index -= 1) { + if ((candidateWidths[index] ?? Infinity) <= boxWidth) { + return { kind: "truncated", text: input.candidates[index] }; + } + } + return { kind: "clipped" }; +} diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index a858c64f71..c93640206a 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -2,7 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry"; import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import type { AgentControlIcon } from "@/agent-controls/icons"; @@ -42,10 +42,13 @@ export const AgentControlTrigger = forwardRef( }, ref, ) { - const { glyphSize } = useComposerControlLayout(); + const { glyphs } = useComposerControlLayout(); + const glyphRole = showToolbarLabel ? "label" : "icon"; + const glyph = glyphs[glyphRole]; const isSheet = surface === "sheet"; - const resolvedGlyphSize = isSheet ? 16 : glyphSize; - const resolvedIconColor = iconColor ?? styles.iconColor.color; + const toolbarIconColor = + glyph.tone === "foreground" ? styles.iconColorForeground.color : styles.iconColor.color; + const resolvedIconColor = iconColor ?? (isSheet ? styles.iconColor.color : toolbarIconColor); const showValue = isSheet || showToolbarLabel; const triggerStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType) => [ @@ -73,11 +76,11 @@ export const AgentControlTrigger = forwardRef( > {isSheet ? ( - + ) : ( - - + + )} {isSheet ? ( @@ -97,18 +100,17 @@ export const AgentControlTrigger = forwardRef( const styles = StyleSheet.create((theme) => ({ toolbarControl: { - height: TOOLBAR_CONTROL_HEIGHT, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, toolbarIconOnly: { - width: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(TOUCH_ICON_SIZE.md), flexShrink: 0, paddingHorizontal: 0, justifyContent: "center", @@ -160,7 +162,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, pressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, disabled: { opacity: 0.5, @@ -168,4 +170,7 @@ const styles = StyleSheet.create((theme) => ({ iconColor: { color: theme.colors.foregroundMuted, }, + iconColorForeground: { + color: theme.colors.foreground, + }, })); diff --git a/packages/app/src/composer/agent-controls/glyph.tsx b/packages/app/src/composer/agent-controls/glyph.tsx index f7d68d38c0..7763d1c2b5 100644 --- a/packages/app/src/composer/agent-controls/glyph.tsx +++ b/packages/app/src/composer/agent-controls/glyph.tsx @@ -1,10 +1,22 @@ import type { ReactNode } from "react"; -import { StyleSheet, View } from "react-native"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { TOOLBAR_GLYPH_SIZES, type ToolbarGlyphs } from "@/components/ui/control-geometry"; -export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) { +/** + * The slot a composer toolbar control's glyph sits in; see `toolbarGlyphsFor`. `label` when the + * glyph sits beside a label, `icon` when the glyph is the whole control. + */ +export function ComposerToolbarGlyph({ + role, + children, +}: { + role: keyof ToolbarGlyphs; + children: ReactNode; +}) { return ( = 20 ? styles.touch : styles.pointer} + style={role === "label" ? styles.labelSlot : styles.iconSlot} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" pointerEvents="none" @@ -14,19 +26,9 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; ); } +const slot = { flexShrink: 0, alignItems: "center", justifyContent: "center" } as const; + const styles = StyleSheet.create({ - pointer: { - width: 16, - height: 16, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, - touch: { - width: 20, - height: 20, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, + iconSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.icon, height: TOOLBAR_GLYPH_SIZES.icon }, + labelSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.label, height: TOOLBAR_GLYPH_SIZES.label }, }); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 63111538e3..f86721fb98 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -61,7 +61,16 @@ import { resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { + controlSizingForLayout, + TOOLBAR_CONTROL_GAP, + bySizing, + toolbarGlyphColor, + type ToolbarGlyphs, + TOUCH_ICON_SIZE, + toolbarContentSlot, + toolbarSlot, +} from "@/components/ui/control-geometry"; import { DensityIcon } from "@/components/ui/density-icon"; import { readMeasuredWidth } from "@/hooks/use-container-width"; import { useToast } from "@/contexts/toast-context"; @@ -79,7 +88,10 @@ import { type ComposerControlDensity, type ComposerControlPresentation, } from "@/composer/agent-controls/layout"; -import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context"; +import { + ComposerControlLayoutProvider, + useComposerControlLayout, +} from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; @@ -219,10 +231,10 @@ function getFeatureIconColor( green: { 400: string }; yellow: { 400: string }; }, - foregroundMuted: string, + restColor: string, ): string { if (!isFeatureActive(feature)) { - return foregroundMuted; + return restColor; } switch (getFeatureHighlightColor(feature.id)) { @@ -233,7 +245,7 @@ function getFeatureIconColor( case "yellow": return palette.yellow[400]; default: - return foregroundMuted; + return restColor; } } @@ -585,7 +597,7 @@ function ControlledAgentControls({ const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); const layoutContextValue = useMemo( () => ({ - glyphSize: toolbarGeometry.glyphSize, + glyphs: toolbarGeometry.glyphs, presentation, }), [presentation, toolbarGeometry], @@ -799,7 +811,7 @@ function ControlledAgentControls({ renderThinkingOption={renderThinkingOption} modeControl={modeControl} presentation={presentation} - glyphSize={layoutContextValue.glyphSize} + glyphs={layoutContextValue.glyphs} activeSheet={activeSheet} handleOpenSheet={handleOpenSheet} handleCloseSheet={handleCloseSheet} @@ -838,7 +850,7 @@ function ControlledAgentControls({ handleOpenChange={handleSheetOpenChange} renderThinkingOption={renderThinkingOption} modeControl={modeControl} - glyphSize={layoutContextValue.glyphSize} + glyphs={layoutContextValue.glyphs} showLabels={presentation.showModelLabel} modelSelectorServerId={modelSelectorServerId} canSwitchProvider={Boolean(onSelectProviderAndModel)} @@ -900,7 +912,7 @@ interface DesktopAgentControlsContentProps { }) => ReactElement; modeControl?: AgentModeControlValue | null; presentation: ComposerControlPresentation; - glyphSize: number; + glyphs: ToolbarGlyphs; activeSheet: ActiveSheet; handleOpenSheet: (sheet: Exclude) => void; handleCloseSheet: () => void; @@ -957,15 +969,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { renderThinkingOption, modeControl, presentation, - glyphSize, + glyphs, activeSheet, handleOpenSheet, handleCloseSheet, modelSelectorServerId, } = props; const modelToolbar = useMemo( - () => ({ glyphSize, showCaret: presentation.showCarets }), - [glyphSize, presentation.showCarets], + () => ({ glyph: glyphs.label, showCaret: presentation.showCarets }), + [glyphs, presentation.showCarets], ); const featuresSheetHeader = useMemo( () => ({ title: t("agentControls.features.title") }), @@ -1086,8 +1098,12 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { accessibilityLabel={t("agentControls.features.open")} testID="agent-controls-features" > - - + + void; }) => ReactElement; modeControl?: AgentModeControlValue | null; - glyphSize: number; + glyphs: ToolbarGlyphs; showLabels: boolean; modelSelectorServerId: string | null; canSwitchProvider: boolean; @@ -1202,7 +1218,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { handleOpenChange, renderThinkingOption, modeControl, - glyphSize, + glyphs, showLabels, modelSelectorServerId, canSwitchProvider, @@ -1291,7 +1307,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { onRetryProvider={onRetryModelProvider} isRetryingProvider={isRetryingModelProvider} serverId={modelSelectorServerId} - glyphSize={glyphSize} + glyphs={glyphs} showLabels={showLabels} canSwitchProvider={canSwitchProvider} > @@ -1316,6 +1332,8 @@ function DesktopFeatureItem({ onActionComplete?: () => void; }) { const { theme } = useUnistyles(); + const { glyphs } = useComposerControlLayout(); + const restColor = toolbarGlyphColor(theme, glyphs.icon.tone); const featureSelector: AgentControlSelector = `feature-${feature.id}`; const featureAnchorRef = useRef(null); @@ -1356,11 +1374,7 @@ function DesktopFeatureItem({ ({ flexShrink: 1, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, + // Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past + // what the row spaces, so the clip reaches as far. overflow: "hidden", + paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }), + marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }), }, modeBadge: { - height: TOOLBAR_CONTROL_HEIGHT, flexDirection: "row", alignItems: "center", backgroundColor: "transparent", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, }, modelControl: { minWidth: 0, @@ -1994,8 +2007,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, modeIconBadge: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(TOUCH_ICON_SIZE.md), alignItems: "center", justifyContent: "center", paddingHorizontal: 0, @@ -2007,7 +2019,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, modeBadgePressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, disabledBadge: { opacity: 0.5, diff --git a/packages/app/src/composer/agent-controls/layout-context.tsx b/packages/app/src/composer/agent-controls/layout-context.tsx index 75b4abac80..86a9b8cbfe 100644 --- a/packages/app/src/composer/agent-controls/layout-context.tsx +++ b/packages/app/src/composer/agent-controls/layout-context.tsx @@ -1,16 +1,17 @@ import { createContext, useContext, type ReactNode } from "react"; +import { toolbarGlyphsFor, type ToolbarGlyphs } from "@/components/ui/control-geometry"; import { resolveComposerControlPresentation, type ComposerControlPresentation, } from "@/composer/agent-controls/layout"; interface ComposerControlLayoutValue { - glyphSize: number; + glyphs: ToolbarGlyphs; presentation: ComposerControlPresentation; } const DEFAULT_LAYOUT: ComposerControlLayoutValue = { - glyphSize: 16, + glyphs: toolbarGlyphsFor("pointer"), presentation: resolveComposerControlPresentation("full"), }; diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts index 2135068bdc..906bc0c48f 100644 --- a/packages/app/src/composer/agent-controls/layout.test.ts +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -3,6 +3,7 @@ import { composerToolbarGeometry, resolveComposerControlDensity, resolveComposerControlPresentation, + resolveModelTriggerLabels, } from "./layout"; const POINTER = composerToolbarGeometry("pointer"); @@ -174,21 +175,29 @@ describe("composer control layout", () => { ).toBe("condensed"); }); - it("gives every toolbar control one shell and one glyph per control sizing", () => { + it("gives every toolbar control one shell and one glyph per role and control sizing", () => { + const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" }; expect(POINTER).toEqual({ controlSize: 28, - glyphSize: 16, + glyphs: { icon: pointerGlyph, label: pointerGlyph }, controlGap: 4, iconLabelGap: 4, labelPadding: 8, caretSize: 14, }); - expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 }); + expect(TOUCH).toMatchObject({ + controlSize: 20, + glyphs: { + icon: { size: 20, strokeWidth: 1.5, tone: "foreground" }, + label: { size: 20, strokeWidth: 1.5, tone: "foreground" }, + }, + controlGap: 20, + }); }); it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => { const input = { - availableWidth: 420, + availableWidth: 400, currentDensity: "full" as const, controls: { hasModel: true, @@ -204,8 +213,8 @@ describe("composer control layout", () => { }); describe("touch toolbar on an iPad in portrait, sidebar docked", () => { - // Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide - // with an empty draft and 224 once an attachment brings in the send button. + // Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they + // paint, 20pt apart, so icon-only thinking, mode and features need 200. const controls = { hasModel: true, hasThinking: true, @@ -217,7 +226,7 @@ describe("composer control layout", () => { it("keeps the model family label with icon-only controls on an empty draft", () => { expect( resolveComposerControlDensity({ - availableWidth: 272, + availableWidth: 280, currentDensity: "full", geometry: TOUCH, controls, @@ -225,10 +234,21 @@ describe("composer control layout", () => { ).toBe("tight"); }); - it("folds thinking, mode and features into the model control once an attachment narrows it", () => { + it("keeps the icon-only controls once an attachment brings in send", () => { expect( resolveComposerControlDensity({ - availableWidth: 224, + availableWidth: 240, + currentDensity: "tight", + geometry: TOUCH, + controls, + }), + ).toBe("tight"); + }); + + it("folds thinking, mode and features into the model control below their width", () => { + expect( + resolveComposerControlDensity({ + availableWidth: 180, currentDensity: "tight", geometry: TOUCH, controls, @@ -239,7 +259,7 @@ describe("composer control layout", () => { it("drops the model label only after the controls are folded", () => { expect( resolveComposerControlDensity({ - availableWidth: 120, + availableWidth: 64, currentDensity: "compact", geometry: TOUCH, controls, @@ -263,8 +283,58 @@ describe("composer control layout", () => { }; expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact"); - // The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits. - expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact"); - expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly"); + // The thinking label is not budgeted: it gives way before the model family label does. + expect(resolveComposerControlDensity({ ...input, availableWidth: 96 })).toBe("compact"); + expect(resolveComposerControlDensity({ ...input, availableWidth: 64 })).toBe("iconOnly"); + }); +}); + +describe("folded model control labels", () => { + it("shows the thinking label while the model label fits beside it in full", () => { + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 191, fullWidth: 190 }, + }), + ).toBe("modelAndThinking"); + }); + + it("hides the thinking label when rounded widths tie", () => { + // On the web, 203.14 of labels and a 203 frame both report 203. + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 203, fullWidth: 203 }, + }), + ).toBe("model"); + }); + + it("hides the thinking label before the model label would truncate", () => { + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 189.5, fullWidth: 190 }, + }), + ).toBe("model"); + expect( + resolveModelTriggerLabels({ + hasThinking: true, + measurements: { availableWidth: 90, fullWidth: 190 }, + }), + ).toBe("model"); + }); + + it("decides nothing until both widths are measured", () => { + expect(resolveModelTriggerLabels({ hasThinking: true, measurements: {} })).toBe("measuring"); + expect( + resolveModelTriggerLabels({ hasThinking: true, measurements: { availableWidth: 300 } }), + ).toBe("measuring"); + expect(resolveModelTriggerLabels({ hasThinking: true, measurements: { fullWidth: 120 } })).toBe( + "measuring", + ); + }); + + it("needs no measurement without a thinking label", () => { + expect(resolveModelTriggerLabels({ hasThinking: false, measurements: {} })).toBe("model"); }); }); diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts index f14950231c..ec6a5b8f26 100644 --- a/packages/app/src/composer/agent-controls/layout.ts +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -1,7 +1,9 @@ import { - iconSizeFor, + TOOLBAR_CONTROL_GAPS, TOOLBAR_CONTROL_SIZES, + toolbarGlyphsFor, type ControlSizing, + type ToolbarGlyphs, } from "@/components/ui/control-geometry"; /** @@ -39,23 +41,32 @@ export interface ComposerControlPresentation { 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; - glyphSize: 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`. */ +/** + * 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: TOOLBAR_CONTROL_SIZES[sizing], - glyphSize: iconSizeFor("md")[sizing], - controlGap: 4, + controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer, + glyphs, + controlGap: TOOLBAR_CONTROL_GAPS[sizing], iconLabelGap: 4, - labelPadding: 8, + labelPadding: isTouch ? 0 : 8, caretSize: 14, }; } @@ -63,8 +74,6 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG 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; -/** A thinking label beside the folded model label, such as "Medium". */ -const THINKING_LABEL_WIDTH = 48; function normalizedFontScale(fontScale: number): number { return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1; @@ -83,7 +92,7 @@ function estimateLabelWidth(label: string, fontScale: number): number { /** 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.glyphSize + geometry.iconLabelGap; + return geometry.labelPadding * 2 + geometry.glyphs.label.size + geometry.iconLabelGap; } function resolveFeatureControlWidth( @@ -147,16 +156,16 @@ function resolveTightFloor( return sumControlWidths(widths, geometry); } -/** One model control carrying the model family label and the thinking label. */ +/** + * 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); - const thinkingLabel = controls.hasThinking - ? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale - : 0; - return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel; + return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale; } function resolveDensityFloor( @@ -251,3 +260,33 @@ export function resolveComposerControlPresentation( ): 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; +}): ModelTriggerLabels { + if (!input.hasThinking) return "model"; + const { availableWidth, fullWidth } = input.measurements; + if (availableWidth === undefined || fullWidth === undefined) return "measuring"; + return fullWidth < availableWidth ? "modelAndThinking" : "model"; +} diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 25352d8a9d..ce6b6d000a 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; @@ -85,7 +85,6 @@ export function AgentModeControl({ surface = "toolbar", onClose, }: AgentModeControlValue & { surface?: "toolbar" | "sheet"; onClose?: () => void }) { - const { theme } = useUnistyles(); const { presentation } = useComposerControlLayout(); const { t } = useTranslation(); const { isActiveComposer } = useComposerKeyboardScope(); @@ -102,7 +101,6 @@ export function AgentModeControl({ }, [modeOptions, selectedModeId]); const Icon = getAgentModeIcon(provider, selectedMode?.id ?? "", providerDefinitions); - const iconColor = theme.colors.foregroundMuted; const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; const allOptions = useMemo( @@ -197,7 +195,6 @@ export function AgentModeControl({ (undefined); + const [fullWidth, setFullWidth] = useState<{ key: string; width: number } | null>(null); + + const onFrameLayout = useCallback((event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setAvailableWidth(width); + }, []); + const onFullLayout = useCallback( + (event: LayoutChangeEvent) => { + const width = readMeasuredWidth(event); + if (width !== null) setFullWidth({ key: measuredKey, width }); + }, + [measuredKey], + ); + + const labels = resolveModelTriggerLabels({ + hasThinking: thinkingLabel !== null, + measurements: { + availableWidth, + fullWidth: fullWidth?.key === measuredKey ? fullWidth.width : undefined, + }, + }); + return { labels, measuredKey, onFrameLayout, onFullLayout }; +} + export function CompactModelSheet({ providers, selectedProvider, @@ -96,7 +148,7 @@ export function CompactModelSheet({ isRetryingProvider = false, disabled = false, serverId = null, - glyphSize, + glyphs, showLabels, canSwitchProvider, children, @@ -233,17 +285,6 @@ export function CompactModelSheet({ open(); }, [close, isOpen, open]); - const iconOnly = !showLabels && hasSelectedProvider; - const triggerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.trigger, - iconOnly && styles.triggerIconOnly, - hovered && styles.triggerHovered, - (pressed || isOpen) && styles.triggerPressed, - disabled && styles.triggerDisabled, - ], - [disabled, iconOnly, isOpen], - ); const mobileRootContent = useMemo( () => ( @@ -269,36 +310,19 @@ export function CompactModelSheet({ return ( <> - - {hasSelectedProvider ? ( - - - - ) : null} - {!iconOnly ? ( - - - {shortModelLabel(rootBrowser.triggerLabel)} - - {thinkingLabel ? ( - - {thinkingLabel} - - ) : null} - - ) : null} - + /> | null; + modelLabel: string; + thinkingLabel: string | null; + glyphs: ToolbarGlyphs; + iconOnly: boolean; + isOpen: boolean; + disabled: boolean; + onPress: () => void; + accessibilityLabel: string; +}) { + const glyphRole = iconOnly ? "icon" : "label"; + const glyph = glyphs[glyphRole]; + const triggerLabels = useModelTriggerLabels({ + modelLabel, + thinkingLabel: iconOnly ? null : thinkingLabel, + glyphSize: glyph.size, + }); + const providerIconColor = + glyph.tone === "foreground" ? styles.providerIconForeground.color : styles.providerIcon.color; + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType) => [ + styles.trigger, + iconOnly && styles.triggerIconOnly, + hovered && styles.triggerHovered, + (pressed || isOpen) && styles.triggerPressed, + disabled && styles.triggerDisabled, + ], + [disabled, iconOnly, isOpen], + ); + + return ( + + + {ModelIcon ? ( + + + + ) : null} + {!iconOnly ? ( + + + {thinkingLabel && triggerLabels.labels !== "model" ? ( + + {thinkingLabel} + + ) : null} + + ) : null} + + {!iconOnly && thinkingLabel ? ( + + {ModelIcon ? {null} : null} + + + {modelLabel} + + + {thinkingLabel} + + + + ) : null} + + ); +} + const styles = StyleSheet.create((theme) => ({ trigger: { - height: TOOLBAR_CONTROL_HEIGHT, minWidth: 0, flexShrink: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], + ...toolbarContentSlot(theme.spacing[2]), + borderRadius: theme.borderRadius.full, backgroundColor: "transparent", }, triggerIconOnly: { - width: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(TOUCH_ICON_SIZE.md), flexShrink: 0, paddingHorizontal: 0, justifyContent: "center", @@ -402,7 +543,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, triggerPressed: { - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, }, triggerDisabled: { opacity: 0.5, @@ -421,6 +562,28 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[1], }, + triggerLabelsMeasuring: { + opacity: 0, + }, + /** The space the trigger may take; the trigger itself stays as wide as its content. */ + triggerFrame: { + minWidth: 0, + flexGrow: 1, + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + }, + /** An invisible copy of the trigger with both labels untruncated. */ + triggerMeasurer: { + position: "absolute", + left: 0, + top: 0, + flexShrink: 0, + opacity: 0, + }, + triggerMeasuredText: { + flexShrink: 0, + }, triggerThinking: { flexShrink: 0, color: theme.colors.foregroundExtraMuted, @@ -430,6 +593,9 @@ const styles = StyleSheet.create((theme) => ({ providerIcon: { color: theme.colors.foregroundMuted, }, + providerIconForeground: { + color: theme.colors.foreground, + }, sheetBody: { paddingHorizontal: 0, paddingTop: 0, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 5401f77d73..b7dd4ee36a 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -33,10 +33,16 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { controlSizingForLayout, - iconSizeFor, - TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_CONTROL_GAP, + TOOLBAR_DISC_GLYPH_SIZES, TOOLBAR_DISC_SIZE, + TOOLBAR_RINGS, + toolbarGlyphsFor, + toolbarGlyphTone, + type ToolbarGlyph, + toolbarSlot, } from "@/components/ui/control-geometry"; +import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails"; import { usePanelStore } from "@/stores/panel-store"; import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { useShallow } from "zustand/shallow"; @@ -260,20 +266,22 @@ function resolveCheckoutRemoteUrl( return checkoutStatus?.remoteUrl ?? null; } -function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { +function buildCancelButtonStyle( + pressed: boolean, + isConnected: boolean, + isCancellingAgent: boolean, +): object[] { + const pressedStyle = pressed ? styles.discPressed : undefined; const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; - return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); + return [styles.cancelButton, pressedStyle, disabled].filter((value): value is object => + Boolean(value), + ); } -function buildRealtimeVoiceButtonStyle( - hovered: boolean | undefined, - voiceButtonDisabled: boolean, - reserveLeadingSpace: boolean, -): object[] { - const hoveredStyle = hovered ? styles.iconButtonHovered : undefined; +function buildRealtimeVoiceButtonStyle(pressed: boolean, voiceButtonDisabled: boolean): object[] { + const pressedStyle = pressed ? styles.discPressed : undefined; const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined; - const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined; - return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter( + return [styles.realtimeVoiceButton, pressedStyle, disabledStyle].filter( (value): value is object => Boolean(value), ); } @@ -1082,8 +1090,8 @@ function ComposerForgeBinding({ } interface ComposerCancelButtonProps { - buttonIconSize: number; - cancelButtonStyle: (object | undefined)[]; + discGlyphSize: number; + cancelButtonStyle: (state: PressableStateCallbackType) => object[]; handleCancelAgent: () => void; isConnected: boolean; isCancellingAgent: boolean; @@ -1092,7 +1100,7 @@ interface ComposerCancelButtonProps { } function ComposerCancelButton({ - buttonIconSize, + discGlyphSize, cancelButtonStyle, handleCancelAgent, isConnected, @@ -1106,7 +1114,7 @@ function ComposerCancelButton({ const icon = isCancellingAgent ? ( ) : ( - + ); const shortcutNode = agentInterruptKeys ? : null; return ( @@ -1131,7 +1139,7 @@ function ComposerCancelButton({ } interface ComposerVoiceModeButtonProps { - buttonIconSize: number; + glyph: ToolbarGlyph; handleToggleRealtimeVoice: () => void; isConnected: boolean; isVoiceSwitching: boolean; @@ -1142,37 +1150,25 @@ interface ComposerVoiceModeButtonProps { t: TFunction; } -interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { +function showsVoiceModeButton(input: { isVoiceModeForAgent: boolean; hasAgent: boolean; isAgentRunning: boolean; hasSendableContent: boolean; - isCompact: boolean; showVoice: boolean; -} - -function ComposerRightControlsSlot({ - isVoiceModeForAgent, - hasAgent, - isAgentRunning, - hasSendableContent, - isCompact, - showVoice, - ...voiceProps -}: ComposerRightControlsSlotProps) { - const hideVoiceForCompactInput = isCompact && hasSendableContent; - const showVoiceModeButton = - showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; - if (!showVoiceModeButton) return null; +}): boolean { + // Voice mode holds the trailing disc until there is something to send, then Send takes it. return ( - - - + input.showVoice && + !input.isVoiceModeForAgent && + input.hasAgent && + !input.isAgentRunning && + !input.hasSendableContent ); } function ComposerVoiceModeButton({ - buttonIconSize, + glyph, handleToggleRealtimeVoice, isConnected, isVoiceSwitching, @@ -1183,13 +1179,25 @@ function ComposerVoiceModeButton({ const shortcutNode = voiceToggleKeys ? : null; const renderTriggerContent = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { - if (isVoiceSwitching) { - return ; - } - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; - return ; + const colorMapping = + toolbarGlyphTone(glyph, Boolean(hovered)) === "foreground" + ? iconForegroundMapping + : iconForegroundMutedMapping; + return ( + + {isVoiceSwitching ? ( + + ) : ( + + )} + + ); }, - [buttonIconSize, isVoiceSwitching], + [glyph, isVoiceSwitching], ); return ( @@ -1311,7 +1319,9 @@ function ComposerContentImpl({ const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); - const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)]; + const controlSizing = controlSizingForLayout(isCompactFormFactor); + const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon; + const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing]; const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const { finePointer: hasFinePointer } = usePointerCapabilities(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); @@ -2012,8 +2022,9 @@ function ComposerContentImpl({ [agentId, recall, replaceUserInput, serverId], ); - const cancelButtonStyle = useMemo( - () => buildCancelButtonStyle(isConnected, isCancellingAgent), + const cancelButtonStyle = useCallback( + (state: PressableStateCallbackType) => + buildCancelButtonStyle(state.pressed, isConnected, isCancellingAgent), [isConnected, isCancellingAgent], ); @@ -2021,14 +2032,14 @@ function ComposerContentImpl({ const voiceButtonDisabled = !isConnected || isVoiceSwitching; const realtimeVoiceButtonStyle = useCallback( (state: PressableStateCallbackType & { hovered?: boolean }) => - buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout), - [isCompactLayout, voiceButtonDisabled], + buildRealtimeVoiceButtonStyle(state.pressed, voiceButtonDisabled), + [voiceButtonDisabled], ); const activeActionContent = useMemo( () => ( ( - - ), + const voiceModeContent = useMemo( + () => + showsVoiceModeButton({ + isVoiceModeForAgent, + hasAgent, + isAgentRunning, + hasSendableContent, + showVoice: mode.showVoice, + }) ? ( + + + + ) : null, [ - buttonIconSize, + toolbarGlyph, handleToggleRealtimeVoice, hasAgent, hasSendableContent, isAgentRunning, isConnected, - isCompactLayout, isVoiceModeForAgent, isVoiceSwitching, mode.showVoice, @@ -2093,7 +2106,7 @@ function ComposerContentImpl({ maxTokens={agentState.contextWindowMaxTokens} usedTokens={agentState.contextWindowUsedTokens} totalCostUsd={agentState.totalCostUsd} - glyphSize={buttonIconSize} + toolbarRing={TOOLBAR_RINGS[controlSizing]} /> ) : null, @@ -2104,7 +2117,7 @@ function ComposerContentImpl({ agentState.contextWindowMaxTokens, agentState.contextWindowUsedTokens, agentState.totalCostUsd, - buttonIconSize, + controlSizing, ], ); @@ -2490,7 +2503,7 @@ function ComposerContentImpl({ disabled={isSubmitLoading} leftContent={leftContent} beforeVoiceContent={beforeVoiceContent} - rightContent={rightContent} + voiceModeContent={voiceModeContent} activeActionContent={activeActionContent} voiceServerId={serverId} voiceAgentId={agentId} @@ -2596,13 +2609,10 @@ const styles = StyleSheet.create((theme: Theme) => ({ width: "100%", gap: theme.spacing[3], }, - // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. cancelButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, cancelButtonDisc: { width: TOOLBAR_DISC_SIZE, @@ -2615,31 +2625,34 @@ const styles = StyleSheet.create((theme: Theme) => ({ rightControls: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, }, contextWindowMeterSlot: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring), flexShrink: 0, alignItems: "center", justifyContent: "center", }, - realtimeVoiceButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + voiceModeDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, alignItems: "center", justifyContent: "center", }, - realtimeVoiceButtonCompactReserve: { - marginLeft: theme.spacing[1], + realtimeVoiceButton: { + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc), + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", }, realtimeVoiceButtonActive: { backgroundColor: theme.colors.palette.green[600], borderColor: theme.colors.palette.green[800], }, - iconButtonHovered: { - backgroundColor: theme.colors.surface2, + discPressed: { + opacity: 0.7, }, attachmentTray: { flexDirection: "row", diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index be1f2c3ed0..3c4541181f 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -31,10 +31,24 @@ import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; import { controlSizingForLayout, - iconSizeFor, + TOOLBAR_CONTROL_GAP, TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_DISC_GLYPH_SIZES, TOOLBAR_DISC_SIZE, + toolbarGlyphsFor, + toolbarGlyphTone, + type ToolbarGlyph, + toolbarSlot, } from "@/components/ui/control-geometry"; +import { + COMPOSER_PADDING_BOTTOM, + COMPOSER_PADDING_HORIZONTAL, + COMPOSER_CORNER_RADIUS, + COMPOSER_PADDING_TOP, + COMPOSER_TOOLBAR_SHAPES, + composerToolbarOverhang, + resolveComposerLeadingEdge, +} from "@/composer/input/toolbar-rails"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import * as Haptics from "expo-haptics"; import { useDictation } from "@/hooks/use-dictation"; @@ -170,8 +184,8 @@ export interface MessageInputProps { leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; - /** Auxiliary content to render on the right side after the voice button. */ - rightContent?: React.ReactNode; + /** The realtime voice mode button after the dictation button, or null when the row hides it. */ + voiceModeContent?: React.ReactNode; /** Primary action to render when the agent is active and the composer has no sendable content. */ activeActionContent?: React.ReactNode; voiceServerId?: string; @@ -252,19 +266,28 @@ interface TextAreaHandle { }; } +function glyphColorMapping(glyph: ToolbarGlyph, hovered: boolean) { + return toolbarGlyphTone(glyph, hovered) === "foreground" + ? iconForegroundMapping + : iconForegroundMutedMapping; +} + function AttachButtonIcon({ hovered, onAttachButtonRef, - buttonIconSize, + glyph, }: { hovered: boolean; onAttachButtonRef: ((node: View | null) => void) | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; }) { - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ( - + ); } @@ -342,25 +365,25 @@ function VoiceButtonIcon({ hovered, isDictating, isMutedRealtime, - buttonIconSize, + glyph, + discGlyphSize, }: { hovered: boolean; isDictating: boolean; isMutedRealtime: boolean; - buttonIconSize: number; + glyph: ToolbarGlyph; + discGlyphSize: number; }) { if (isDictating) { return ( - + ); } - const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; - if (isMutedRealtime) { - return ; - } - return ; + const colorMapping = glyphColorMapping(glyph, hovered); + const Icon = isMutedRealtime ? ThemedMicOff : ThemedMic; + return ; } type ShortcutChord = NonNullable["chord"]>; @@ -399,12 +422,12 @@ function SendButtonContent({ isSubmitLoading, submitIcon, submitLabel, - buttonIconSize, + glyph, }: { isSubmitLoading: boolean; submitIcon: "arrow" | "return"; submitLabel: string | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; }) { if (isSubmitLoading) { return ; @@ -413,9 +436,21 @@ function SendButtonContent({ return {submitLabel}; } if (submitIcon === "return") { - return ; + return ( + + ); } - return ; + return ( + + ); } interface DesktopKeyPressContext { @@ -808,7 +843,7 @@ function SendButtonTooltip({ submitIcon, submitLabel, submitButtonTestID, - buttonIconSize, + glyph, sendKeys, sendTooltipLabel, }: { @@ -825,7 +860,7 @@ function SendButtonTooltip({ submitIcon: "arrow" | "return"; submitLabel: string | undefined; submitButtonTestID: string | undefined; - buttonIconSize: number; + glyph: ToolbarGlyph; sendKeys: ShortcutChord | null | undefined; sendTooltipLabel: string; }) { @@ -847,7 +882,7 @@ function SendButtonTooltip({ isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} submitLabel={submitLabel} - buttonIconSize={buttonIconSize} + glyph={glyph} /> @@ -1140,7 +1175,7 @@ interface ResolvedMessageInputProps { disabled: boolean; leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; - rightContent: React.ReactNode; + voiceModeContent: React.ReactNode; activeActionContent: React.ReactNode; voiceServerId: string | undefined; voiceAgentId: string | undefined; @@ -1188,7 +1223,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu disabled: props.disabled ?? false, leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, - rightContent: props.rightContent, + voiceModeContent: props.voiceModeContent, activeActionContent: props.activeActionContent, voiceServerId: props.voiceServerId, voiceAgentId: props.voiceAgentId, @@ -1216,8 +1251,12 @@ function extractErrorMessage(error: unknown): string | null { return null; } +/** + * Send that stands in for the trailing disc, in fullscreen or while there is nothing to send, is + * disabled until there is content. + */ function resolvePresentationSendDisabled(input: { - fullscreen: boolean; + standsIn: boolean; disabled: boolean; hasText: boolean; attachments: ComposerAttachment[]; @@ -1225,7 +1264,7 @@ function resolvePresentationSendDisabled(input: { allowEmpty: boolean; }): boolean { return ( - input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input)) + input.disabled || (input.standsIn && !input.allowEmpty && !hasSendableComposerContent(input)) ); } @@ -1266,7 +1305,7 @@ export const MessageInput = forwardRef( disabled, leftContent, beforeVoiceContent, - rightContent, + voiceModeContent, activeActionContent, voiceServerId, voiceAgentId, @@ -1296,7 +1335,8 @@ export const MessageInput = forwardRef( const [renderedLines, setRenderedLines] = useState(1); const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact); const controlSizing = controlSizingForLayout(isCompact); - const buttonIconSize = iconSizeFor("md")[controlSizing]; + const toolbarGlyph = toolbarGlyphsFor(controlSizing).icon; + const discGlyphSize = TOOLBAR_DISC_GLYPH_SIZES[controlSizing]; const toast = useToast(); const voice = useVoiceOptional(); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); @@ -1919,18 +1959,20 @@ export const MessageInput = forwardRef( ); const attachButtonStyle = useCallback( - ({ hovered }: { hovered?: boolean }) => [ + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.attachButton, - Boolean(hovered) && styles.iconButtonHovered, + Boolean(hovered || pressed) && styles.iconButtonHovered, (!isConnected || disabled) && styles.buttonDisabled, ], [isConnected, disabled], ); const voiceButtonStyle = useCallback( - ({ hovered }: { hovered?: boolean }) => [ + ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.voiceButton, - Boolean(hovered) && !isDictating && styles.iconButtonHovered, + isDictating && styles.voiceButtonRecording, + Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered, + Boolean(pressed) && isDictating && styles.discPressed, !isDictationStartEnabled && styles.buttonDisabled, ], [isDictating, isDictationStartEnabled], @@ -1971,16 +2013,24 @@ export const MessageInput = forwardRef( [mode.isMonospace], ); // Fullscreen always shows Send; without content it is disabled, and looks it. + // With nothing to send and no voice mode, a disabled Send holds the trailing disc so the row + // does not shift when typing starts. + const sendStandsIn = isFullscreen || (primaryActionKind === "none" && voiceModeContent == null); + const presentedActionKind: PrimaryActionKind = sendStandsIn ? "send" : primaryActionKind; const isPresentedSendDisabled = resolvePresentationSendDisabled({ - fullscreen: isFullscreen, + standsIn: sendStandsIn, disabled: isSendButtonDisabled, hasText: hasLiveText, attachments, hasExternalContent, allowEmpty: allowEmptySubmit, }); - const sendButtonCombinedStyle = useMemo( - () => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled], + const sendButtonCombinedStyle = useCallback( + ({ pressed }: { pressed?: boolean }) => [ + styles.sendButton, + Boolean(pressed) && styles.discPressed, + isPresentedSendDisabled && styles.buttonDisabled, + ], [isPresentedSendDisabled], ); const overlayContainerStyle = useMemo( @@ -1993,10 +2043,10 @@ export const MessageInput = forwardRef( ), - [onAttachButtonRef, buttonIconSize], + [onAttachButtonRef, toolbarGlyph], ); const renderVoiceButtonIcon = useCallback( @@ -2005,16 +2055,17 @@ export const MessageInput = forwardRef( hovered={Boolean(hovered)} isDictating={isDictating} isMutedRealtime={Boolean(isRealtimeVoiceForCurrentAgent && voice?.isMuted)} - buttonIconSize={buttonIconSize} + glyph={toolbarGlyph} + discGlyphSize={discGlyphSize} /> ), - [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], + [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, toolbarGlyph, discGlyphSize], ); const sendButton = useMemo( () => ( ( submitIcon={submitIcon} submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} - buttonIconSize={buttonIconSize} + glyph={toolbarGlyph} sendKeys={resolveSendKeys(sendShortcut)} sendTooltipLabel={sendTooltipLabel} /> ), [ - isFullscreen, - primaryActionKind, + presentedActionKind, activeActionContent, canPressLoadingButton, onSubmitLoadingPress, @@ -2050,7 +2100,7 @@ export const MessageInput = forwardRef( submitIcon, submitLabel, submitButtonTestID, - buttonIconSize, + toolbarGlyph, sendShortcut, sendTooltipLabel, ], @@ -2124,9 +2174,25 @@ export const MessageInput = forwardRef( focusInputKeys, ], ); + const leadingEdge = resolveComposerLeadingEdge({ + showsAttach: mode.showAttachments, + showsAgentControls: leftContent != null, + }); + // The trailing control is always a disc: voice mode, then Send or Stop. + const trailingEdge = "disc"; + const buttonRowStyle = useMemo( + () => [ + styles.buttonRow, + { + marginLeft: -composerToolbarOverhang(leadingEdge, controlSizing), + marginRight: -composerToolbarOverhang(trailingEdge, controlSizing), + }, + ], + [controlSizing, leadingEdge, trailingEdge], + ); const toolbar = useMemo( () => ( - + {/* Toolbar left: attachment button + agent controls */} ( voiceMuteToggleKeys={voiceMuteToggleKeys} dictationToggleKeys={dictationToggleKeys} /> - {rightContent} + {voiceModeContent} {sendButton} ), [ + buttonRowStyle, mode.showAttachments, isConnected, disabled, @@ -2181,7 +2248,7 @@ export const MessageInput = forwardRef( isRealtimeVoiceForCurrentAgent, voiceMuteToggleKeys, dictationToggleKeys, - rightContent, + voiceModeContent, sendButton, ], ); @@ -2249,7 +2316,7 @@ export const MessageInput = forwardRef( ({ backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius["2xl"], - paddingTop: { - xs: theme.spacing[2], - md: theme.spacing[4], - }, - // The button row bleeds 6px horizontally, so match its corner inset at the bottom. - paddingBottom: { - xs: theme.spacing[2], - md: theme.spacing[3], - }, - paddingHorizontal: { - xs: theme.spacing[3], - md: theme.spacing[4], - }, + borderRadius: COMPOSER_CORNER_RADIUS, + paddingTop: COMPOSER_PADDING_TOP, + paddingBottom: COMPOSER_PADDING_BOTTOM, + paddingHorizontal: COMPOSER_PADDING_HORIZONTAL, ...(isWeb ? { transitionProperty: "border-color", @@ -2377,47 +2434,49 @@ const styles = StyleSheet.create((theme: Theme) => ({ minHeight: MIN_INPUT_HEIGHT, color: theme.colors.foregroundMuted, }, + // The row keeps a full touch target's height; its disc-sized controls centre in it. buttonRow: { + minHeight: TOOLBAR_CONTROL_HEIGHT, flexShrink: 0, flexDirection: "row", - alignItems: "flex-end", + alignItems: "center", justifyContent: "space-between", - marginHorizontal: -6, }, leftButtonGroup: { minWidth: 0, flexShrink: 1, flexGrow: 1, flexDirection: "row", - alignItems: "flex-end", - gap: theme.spacing[0], + alignItems: "center", + gap: TOOLBAR_CONTROL_GAP, }, rightButtonGroup: { flexShrink: 0, flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: TOOLBAR_CONTROL_GAP, }, attachButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, attachButtonAnchor: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, alignItems: "center", justifyContent: "center", }, voiceButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + ...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation), borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, + voiceButtonRecording: { + marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, + }, voiceButtonRecordingDisc: { width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE, @@ -2426,13 +2485,12 @@ const styles = StyleSheet.create((theme: Theme) => ({ alignItems: "center", justifyContent: "center", }, - // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. sendButton: { - minWidth: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + minWidth: TOOLBAR_DISC_SIZE, + marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal, + height: TOOLBAR_DISC_SIZE, alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, sendButtonDisc: { width: TOOLBAR_DISC_SIZE, @@ -2459,6 +2517,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ iconButtonHovered: { backgroundColor: theme.colors.surface2, }, + discPressed: { + opacity: 0.7, + }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/composer/input/toolbar-rails.test.ts b/packages/app/src/composer/input/toolbar-rails.test.ts new file mode 100644 index 0000000000..1b4caadd2d --- /dev/null +++ b/packages/app/src/composer/input/toolbar-rails.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { composerToolbarOverhang, resolveComposerLeadingEdge } from "./toolbar-rails"; + +describe("composer toolbar rails", () => { + it("keeps touch glyphs and labels on the text rails and nests a trailing disc into the corner", () => { + for (const edge of ["attach", "dictation", "voiceMode", "label", "none"] as const) { + expect(composerToolbarOverhang(edge, "touch")).toBe(0); + } + // A 36pt disc centred in the 44pt row sits 8 from the bottom edge, so it sits 8 from the side. + expect(composerToolbarOverhang("disc", "touch")).toBe(8); + }); + + it("keeps the pointer row's flat overhang", () => { + for (const edge of ["attach", "dictation", "voiceMode", "disc", "label", "none"] as const) { + expect(composerToolbarOverhang(edge, "pointer")).toBe(6); + } + }); + + it("starts the row on attach, else on the agent controls", () => { + expect(resolveComposerLeadingEdge({ showsAttach: true, showsAgentControls: true })).toBe( + "attach", + ); + expect(resolveComposerLeadingEdge({ showsAttach: false, showsAgentControls: true })).toBe( + "label", + ); + expect(resolveComposerLeadingEdge({ showsAttach: false, showsAgentControls: false })).toBe( + "none", + ); + }); +}); diff --git a/packages/app/src/composer/input/toolbar-rails.ts b/packages/app/src/composer/input/toolbar-rails.ts new file mode 100644 index 0000000000..1cbca21224 --- /dev/null +++ b/packages/app/src/composer/input/toolbar-rails.ts @@ -0,0 +1,85 @@ +import { + TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_SIZES, + TOOLBAR_RINGS, + toolbarGlyphsFor, + type ControlSizing, +} from "@/components/ui/control-geometry"; +import { bySizing } from "@/components/ui/control-sizing"; + +/** The control at one end of the composer toolbar, which decides how far the row hangs out. */ +export type ComposerToolbarEdge = "attach" | "label" | "dictation" | "voiceMode" | "disc" | "none"; + +/** + * The composer's padding. Its sides are the rails: the text column and the toolbar's outer + * controls both start there. + */ +const COMPOSER_PADDING = { + touch: { top: 12, horizontal: 16, bottom: 4 }, + pointer: { top: 16, horizontal: 16, bottom: 12 }, +} as const; + +export const COMPOSER_PADDING_TOP = bySizing({ + touch: COMPOSER_PADDING.touch.top, + pointer: COMPOSER_PADDING.pointer.top, +}); +export const COMPOSER_PADDING_HORIZONTAL = bySizing({ + touch: COMPOSER_PADDING.touch.horizontal, + pointer: COMPOSER_PADDING.pointer.horizontal, +}); +export const COMPOSER_PADDING_BOTTOM = bySizing({ + touch: COMPOSER_PADDING.touch.bottom, + pointer: COMPOSER_PADDING.pointer.bottom, +}); + +/** How much of Lucide's 24-unit grid each toolbar glyph's ink spans, centred in its box. */ +const GLYPH_INK_SPANS = { plus: 14, mic: 14, audioLines: 20 } as const; + +function touchGlyphInkWidth(span: number): number { + const glyph = toolbarGlyphsFor("touch").icon; + return (span * glyph.size) / 24 + glyph.strokeWidth; +} + +/** The width each toolbar control paints when touch-sized; the row spaces these. */ +export const COMPOSER_TOOLBAR_SHAPES = { + attach: touchGlyphInkWidth(GLYPH_INK_SPANS.plus), + dictation: touchGlyphInkWidth(GLYPH_INK_SPANS.mic), + voiceMode: touchGlyphInkWidth(GLYPH_INK_SPANS.audioLines), + ring: TOOLBAR_RINGS.touch.size, + disc: TOOLBAR_DISC_SIZES.touch, +} as const; + +/** Pointer-sized the row hangs a flat 6px into the padding, which puts each 16pt glyph box on the rail. */ +const POINTER_OVERHANG = 6; + +/** How far a touch-sized disc sits from the composer's bottom edge, centred in the toolbar row. */ +const DISC_EDGE_INSET = + COMPOSER_PADDING.touch.bottom + (TOOLBAR_CONTROL_SIZES.touch - TOOLBAR_DISC_SIZES.touch) / 2; + +/** + * A trailing disc nests into the composer's corner: it reaches past the rail until it sits as far + * from the right edge as from the bottom edge. + */ +const DISC_CORNER_NEST = COMPOSER_PADDING.touch.horizontal - DISC_EDGE_INSET; + +/** Touch-sized the composer's corner is concentric with the disc nested in it. */ +export const COMPOSER_CORNER_RADIUS = bySizing({ + touch: TOOLBAR_DISC_SIZES.touch / 2 + DISC_EDGE_INSET, + pointer: 16, +}); + +/** How far the composer toolbar hangs past the text column at one end. */ +export function composerToolbarOverhang(edge: ComposerToolbarEdge, sizing: ControlSizing): number { + if (sizing === "pointer") return POINTER_OVERHANG; + return edge === "disc" ? DISC_CORNER_NEST : 0; +} + +/** The control that starts the toolbar on the left. */ +export function resolveComposerLeadingEdge(input: { + showsAttach: boolean; + showsAgentControls: boolean; +}): ComposerToolbarEdge { + if (input.showsAttach) return "attach"; + if (input.showsAgentControls) return "label"; + return "none"; +}