| author | |
| committer | |
| log | 044ea8e347d980a215057c325de8335d38f0f9aa |
| tree | a4819e36b4b996bdf53637321ea1bf2674ec71c3 |
| parent | 18573d60aab1344bf124a044b01445086e910c4c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>7 files changed, 245 insertions(+), 102 deletions(-)
apps/web/src/components/BranchToolbar.logic.ts+14| ... | @@ -68,6 +68,20 @@ export function shouldShowComposerContextStrip(input: { | ... | @@ -68,6 +68,20 @@ export function shouldShowComposerContextStrip(input: { |
| 68 | ); | 68 | ); |
| 69 | } | 69 | } |
| 70 | 70 | ||
| 71 | // Labels collapse to icons when the strip's content no longer fits. A small | ||
| 72 | // hysteresis on the way back out keeps the boundary from flapping. | ||
| 73 | const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16; | ||
| 74 | |||
| 75 | export function resolveContextStripLabelsCompact(input: { | ||
| 76 | compact: boolean; | ||
| 77 | neededWidth: number; | ||
| 78 | availableWidth: number; | ||
| 79 | }): boolean { | ||
| 80 | return input.compact | ||
| 81 | ? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX | ||
| 82 | : input.neededWidth > input.availableWidth; | ||
| 83 | } | ||
| 84 | |||
| 71 | export function resolveEnvModeLabel(mode: EnvMode): string { | 85 | export function resolveEnvModeLabel(mode: EnvMode): string { |
| 72 | return mode === "worktree" ? "New worktree" : "Current checkout"; | 86 | return mode === "worktree" ? "New worktree" : "Current checkout"; |
| 73 | } | 87 | } |
apps/web/src/components/BranchToolbar.tsx+19-7| ... | @@ -15,6 +15,7 @@ import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/e | ... | @@ -15,6 +15,7 @@ import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/e |
| 15 | import { | 15 | import { |
| 16 | type EnvMode, | 16 | type EnvMode, |
| 17 | type EnvironmentOption, | 17 | type EnvironmentOption, |
| 18 | resolveContextStripLabelsCompact, | ||
| 18 | resolveCurrentWorkspaceLabel, | 19 | resolveCurrentWorkspaceLabel, |
| 19 | resolveEnvModeLabel, | 20 | resolveEnvModeLabel, |
| 20 | resolveEffectiveEnvMode, | 21 | resolveEffectiveEnvMode, |
| ... | @@ -39,6 +40,8 @@ import { | ... | @@ -39,6 +40,8 @@ import { |
| 39 | } from "./ui/menu"; | 40 | } from "./ui/menu"; |
| 40 | import { Separator } from "./ui/separator"; | 41 | import { Separator } from "./ui/separator"; |
| 41 | import { ComposerSurface } from "./chat/ComposerSurface"; | 42 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 43 | import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; | ||
| 44 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; | ||
| 42 | import { cn } from "~/lib/utils"; | 45 | import { cn } from "~/lib/utils"; |
| 43 | 46 | ||
| 44 | interface BranchToolbarProps { | 47 | interface BranchToolbarProps { |
| ... | @@ -236,7 +239,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -236,7 +239,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 236 | * the expanded width without remembered values that could go stale or latch | 239 | * the expanded width without remembered values that could go stale or latch |
| 237 | * the strip compact. A small hysteresis keeps the boundary from flapping. | 240 | * the strip compact. A small hysteresis keeps the boundary from flapping. |
| 238 | */ | 241 | */ |
| 239 | const COMPACT_EXPAND_HYSTERESIS_PX = 16; | ||
| 240 | const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; | 242 | const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; |
| 241 | const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; | 243 | const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; |
| 242 | const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; | 244 | const COMPOSER_CONTEXT_CONTROL_SELECTOR = "[data-composer-context-control]"; |
| ... | @@ -282,12 +284,20 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { | ... | @@ -282,12 +284,20 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 282 | let groups = 0; | 284 | let groups = 0; |
| 283 | for (const child of current.children) { | 285 | for (const child of current.children) { |
| 284 | if (!(child instanceof HTMLElement)) continue; | 286 | if (!(child instanceof HTMLElement)) continue; |
| 285 | // The host itself flexes into all remaining room. Measure the controls | 287 | // The host itself flexes into all remaining room. Reserve the natural |
| 286 | // inside it so Git labels compact before squeezing out the model picker. | 288 | // width of the controls inside it, blocks in overflow included, so Git |
| 289 | // labels compact before squeezing out the model picker. Reserving only | ||
| 290 | // the visible controls would let the labels expand into room the | ||
| 291 | // composer just freed, shrink the host, and hide the controls again. | ||
| 287 | const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') | 292 | const hostedControls = child.matches('[data-chat-resting-composer-controls-host="true"]') |
| 288 | ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]') | 293 | ? child.querySelector<HTMLElement>('[data-chat-composer-resting-controls="true"]') |
| 289 | : null; | 294 | : null; |
| 290 | const width = hostedControls ? contentWidth(hostedControls) : contentWidth(child); | 295 | const hostedMeasurement = hostedControls |
| 296 | ? measureRestingComposerControls(hostedControls) | ||
| 297 | : null; | ||
| 298 | const width = hostedMeasurement | ||
| 299 | ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) | ||
| 300 | : contentWidth(hostedControls ?? child); | ||
| 291 | if (width <= 1) continue; | 301 | if (width <= 1) continue; |
| 292 | groups += 1; | 302 | groups += 1; |
| 293 | needed += width; | 303 | needed += width; |
| ... | @@ -311,9 +321,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { | ... | @@ -311,9 +321,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 311 | needed += Math.max(0, textWidth - label.clientWidth); | 321 | needed += Math.max(0, textWidth - label.clientWidth); |
| 312 | } | 322 | } |
| 313 | } | 323 | } |
| 314 | const nextOverflows = compact | 324 | const nextOverflows = resolveContextStripLabelsCompact({ |
| 315 | ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX | 325 | compact, |
| 316 | : needed > available; | 326 | neededWidth: needed, |
| 327 | availableWidth: available, | ||
| 328 | }); | ||
| 317 | if (nextOverflows !== compact) { | 329 | if (nextOverflows !== compact) { |
| 318 | pendingControlRectsRef.current = new Map( | 330 | pendingControlRectsRef.current = new Map( |
| 319 | Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( | 331 | Array.from(current.querySelectorAll<HTMLElement>(COMPOSER_CONTEXT_CONTROL_SELECTOR)).map( |
apps/web/src/components/chat/ChatComposer.tsx+6-72| ... | @@ -150,6 +150,7 @@ import { | ... | @@ -150,6 +150,7 @@ import { |
| 150 | shouldUseCompactComposerFooter, | 150 | shouldUseCompactComposerFooter, |
| 151 | shouldUseRestingComposerLayout, | 151 | shouldUseRestingComposerLayout, |
| 152 | } from "../composerFooterLayout"; | 152 | } from "../composerFooterLayout"; |
| 153 | import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; | ||
| 153 | import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; | 154 | import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; |
| 154 | import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; | 155 | import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; |
| 155 | import { ProviderModelPicker } from "./ProviderModelPicker"; | 156 | import { ProviderModelPicker } from "./ProviderModelPicker"; |
| ... | @@ -852,44 +853,6 @@ const terminalContextIdListsEqual = ( | ... | @@ -852,44 +853,6 @@ const terminalContextIdListsEqual = ( |
| 852 | ): boolean => | 853 | ): boolean => |
| 853 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); | 854 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); |
| 854 | 855 | ||
| 855 | function elementOuterWidth(element: HTMLElement): number { | ||
| 856 | const width = element.getBoundingClientRect().width; | ||
| 857 | if (width === 0) return 0; | ||
| 858 | const style = getComputedStyle(element); | ||
| 859 | return ( | ||
| 860 | width + | ||
| 861 | (Number.parseFloat(style.marginInlineStart) || 0) + | ||
| 862 | (Number.parseFloat(style.marginInlineEnd) || 0) | ||
| 863 | ); | ||
| 864 | } | ||
| 865 | |||
| 866 | function elementInlineMarginWidth(element: HTMLElement): number { | ||
| 867 | const style = getComputedStyle(element); | ||
| 868 | return ( | ||
| 869 | (Number.parseFloat(style.marginInlineStart) || 0) + | ||
| 870 | (Number.parseFloat(style.marginInlineEnd) || 0) | ||
| 871 | ); | ||
| 872 | } | ||
| 873 | |||
| 874 | function providerModelPickerNaturalWidth(picker: HTMLElement): number { | ||
| 875 | const renderedWidth = picker.getBoundingClientRect().width; | ||
| 876 | if (renderedWidth === 0) return 0; | ||
| 877 | const style = getComputedStyle(picker); | ||
| 878 | const label = picker.querySelector<HTMLElement>('[data-chat-provider-model-picker-label="true"]'); | ||
| 879 | const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; | ||
| 880 | const maxWidth = Number.parseFloat(style.maxWidth); | ||
| 881 | const naturalWidth = Math.min( | ||
| 882 | renderedWidth + hiddenLabelWidth, | ||
| 883 | Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY, | ||
| 884 | ); | ||
| 885 | return naturalWidth + elementInlineMarginWidth(picker); | ||
| 886 | } | ||
| 887 | |||
| 888 | function providerModelPickerMinimumWidth(picker: HTMLElement): number { | ||
| 889 | const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0; | ||
| 890 | return minWidth + elementInlineMarginWidth(picker); | ||
| 891 | } | ||
| 892 | |||
| 893 | function useRestingComposerControlsLayout(host: HTMLDivElement | null) { | 856 | function useRestingComposerControlsLayout(host: HTMLDivElement | null) { |
| 894 | const controlsRef = useRef<HTMLDivElement>(null); | 857 | const controlsRef = useRef<HTMLDivElement>(null); |
| 895 | const hostRef = useRef(host); | 858 | const hostRef = useRef(host); |
| ... | @@ -903,41 +866,12 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) { | ... | @@ -903,41 +866,12 @@ function useRestingComposerControlsLayout(host: HTMLDivElement | null) { |
| 903 | // composer pays no layout reads here despite the every-render effect. | 866 | // composer pays no layout reads here despite the every-render effect. |
| 904 | if (currentHost === null || !controls) return; | 867 | if (currentHost === null || !controls) return; |
| 905 | 868 | ||
| 906 | const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]")); | 869 | const measurement = measureRestingComposerControls(controls); |
| 907 | 870 | if (!measurement) return; | |
| 908 | // Hidden blocks and the unused overflow trigger stay mounted out of flow | ||
| 909 | // at full size. The picker is the one flexible item: recover its intended | ||
| 910 | // width from the truncated label, then let it contract only after all | ||
| 911 | // trailing blocks have moved into overflow. | ||
| 912 | const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0; | ||
| 913 | const picker = controls.querySelector<HTMLElement>("[data-chat-provider-model-picker]"); | ||
| 914 | const leadingControl = | ||
| 915 | picker ?? controls.querySelector<HTMLElement>('[data-chat-provider-unavailable="true"]'); | ||
| 916 | if (!leadingControl) return; | ||
| 917 | // Separators are display:none on phone widths; a hidden one takes no gap. | ||
| 918 | const separator = controls.querySelector<HTMLElement>("[data-resting-controls-separator]"); | ||
| 919 | const separatorWidth = separator ? elementOuterWidth(separator) : 0; | ||
| 920 | const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]"); | ||
| 921 | const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; | ||
| 922 | const naturalFixedWidth = | ||
| 923 | (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) + | ||
| 924 | separatorAndGapWidth; | ||
| 925 | const minimumFixedWidth = | ||
| 926 | (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + | ||
| 927 | separatorAndGapWidth; | ||
| 928 | const blockWidths = blocks.map(elementOuterWidth); | ||
| 929 | const overflowWidth = overflow ? elementOuterWidth(overflow) : 0; | ||
| 930 | const hostWidth = currentHost.clientWidth; | 871 | const hostWidth = currentHost.clientWidth; |
| 931 | 872 | ||
| 932 | setLayout((current) => { | 873 | setLayout((current) => { |
| 933 | const next = resolveRestingComposerControlsLayout({ | 874 | const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); |
| 934 | hostWidth, | ||
| 935 | gap, | ||
| 936 | naturalFixedWidth, | ||
| 937 | minimumFixedWidth, | ||
| 938 | blockWidths, | ||
| 939 | overflowWidth, | ||
| 940 | }); | ||
| 941 | return next.hiddenCount === current.hiddenCount && next.visible === current.visible | 875 | return next.hiddenCount === current.hiddenCount && next.visible === current.visible |
| 942 | ? current | 876 | ? current |
| 943 | : next; | 877 | : next; |
| ... | @@ -3921,7 +3855,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3921,7 +3855,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3921 | const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; | 3855 | const hidden = index >= restingBlockDefs.length - restingHiddenBlockCount; |
| 3922 | return ( | 3856 | return ( |
| 3923 | <div | 3857 | <div |
| 3924 | key={`${def.id}:${hidden ? "hidden" : "visible"}`} | 3858 | key={def.id} |
| 3925 | data-resting-block={def.id} | 3859 | data-resting-block={def.id} |
| 3926 | aria-hidden={hidden || undefined} | 3860 | aria-hidden={hidden || undefined} |
| 3927 | inert={hidden || undefined} | 3861 | inert={hidden || undefined} |
| ... | @@ -3936,7 +3870,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3936,7 +3870,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3936 | })} | 3870 | })} |
| 3937 | {composerControlsInStrip ? ( | 3871 | {composerControlsInStrip ? ( |
| 3938 | <div | 3872 | <div |
| 3939 | key={hiddenRestingBlockIds.length > 0 ? "overflow:visible" : "overflow:hidden"} | ||
| 3940 | data-resting-controls-overflow | 3873 | data-resting-controls-overflow |
| 3941 | aria-hidden={hiddenRestingBlockIds.length === 0 || undefined} | 3874 | aria-hidden={hiddenRestingBlockIds.length === 0 || undefined} |
| 3942 | inert={hiddenRestingBlockIds.length === 0 || undefined} | 3875 | inert={hiddenRestingBlockIds.length === 0 || undefined} |
| ... | @@ -3949,6 +3882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3949,6 +3882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3949 | interactionMode={interactionMode} | 3882 | interactionMode={interactionMode} |
| 3950 | runtimeMode={runtimeMode} | 3883 | runtimeMode={runtimeMode} |
| 3951 | size="xs" | 3884 | size="xs" |
| 3885 | hidden={hiddenRestingBlockIds.length === 0} | ||
| 3952 | showInteractionModeToggle={ | 3886 | showInteractionModeToggle={ |
| 3953 | composerProviderControls.showInteractionModeToggle && | 3887 | composerProviderControls.showInteractionModeToggle && |
| 3954 | hiddenRestingBlockIds.includes("mode") | 3888 | hiddenRestingBlockIds.includes("mode") |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+18-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; | 1 | import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; |
| 2 | import { memo, type ReactNode } from "react"; | 2 | import { memo, type ReactNode, useState } from "react"; |
| 3 | import { EllipsisIcon } from "lucide-react"; | 3 | import { EllipsisIcon } from "lucide-react"; |
| 4 | import { | 4 | import { |
| 5 | Menu, | 5 | Menu, |
| ... | @@ -18,13 +18,29 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls | ... | @@ -18,13 +18,29 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 18 | showInteractionModeToggle: boolean; | 18 | showInteractionModeToggle: boolean; |
| 19 | traitsMenuContent?: ReactNode; | 19 | traitsMenuContent?: ReactNode; |
| 20 | size?: "sm" | "xs"; | 20 | size?: "sm" | "xs"; |
| 21 | /** | ||
| 22 | * The resting strip keeps this menu mounted out of flow while every block | ||
| 23 | * fits inline. Its portaled popup would outlive that transition, so an | ||
| 24 | * open menu closes when its trigger hides. | ||
| 25 | */ | ||
| 26 | hidden?: boolean; | ||
| 21 | onToggleInteractionMode: () => void; | 27 | onToggleInteractionMode: () => void; |
| 22 | onRuntimeModeChange: (mode: RuntimeMode) => void; | 28 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| 23 | }) { | 29 | }) { |
| 24 | const size = props.size ?? "sm"; | 30 | const size = props.size ?? "sm"; |
| 31 | const [open, setOpen] = useState(false); | ||
| 32 | const hidden = props.hidden ?? false; | ||
| 33 | // Base UI does not report a close it did not initiate, so clear the state | ||
| 34 | // when the trigger hides or the menu would reopen by itself when the | ||
| 35 | // trigger returns. | ||
| 36 | const [wasHidden, setWasHidden] = useState(hidden); | ||
| 37 | if (hidden !== wasHidden) { | ||
| 38 | setWasHidden(hidden); | ||
| 39 | if (hidden) setOpen(false); | ||
| 40 | } | ||
| 25 | 41 | ||
| 26 | return ( | 42 | return ( |
| 27 | <Menu> | 43 | <Menu open={open && !hidden} onOpenChange={setOpen}> |
| 28 | <MenuTrigger | 44 | <MenuTrigger |
| 29 | render={ | 45 | render={ |
| 30 | <ComposerControl | 46 | <ComposerControl |
apps/web/src/components/chat/restingComposerControlsMeasurement.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import type { RestingComposerControlsMeasurement } from "../composerFooterLayout"; | ||
| 2 | |||
| 3 | function elementOuterWidth(element: HTMLElement): number { | ||
| 4 | const width = element.getBoundingClientRect().width; | ||
| 5 | if (width === 0) return 0; | ||
| 6 | const style = getComputedStyle(element); | ||
| 7 | return ( | ||
| 8 | width + | ||
| 9 | (Number.parseFloat(style.marginInlineStart) || 0) + | ||
| 10 | (Number.parseFloat(style.marginInlineEnd) || 0) | ||
| 11 | ); | ||
| 12 | } | ||
| 13 | |||
| 14 | function elementInlineMarginWidth(element: HTMLElement): number { | ||
| 15 | const style = getComputedStyle(element); | ||
| 16 | return ( | ||
| 17 | (Number.parseFloat(style.marginInlineStart) || 0) + | ||
| 18 | (Number.parseFloat(style.marginInlineEnd) || 0) | ||
| 19 | ); | ||
| 20 | } | ||
| 21 | |||
| 22 | function providerModelPickerNaturalWidth(picker: HTMLElement): number { | ||
| 23 | const renderedWidth = picker.getBoundingClientRect().width; | ||
| 24 | if (renderedWidth === 0) return 0; | ||
| 25 | const style = getComputedStyle(picker); | ||
| 26 | const label = picker.querySelector<HTMLElement>('[data-chat-provider-model-picker-label="true"]'); | ||
| 27 | const hiddenLabelWidth = label ? Math.max(0, label.scrollWidth - label.clientWidth) : 0; | ||
| 28 | const maxWidth = Number.parseFloat(style.maxWidth); | ||
| 29 | const naturalWidth = Math.min( | ||
| 30 | renderedWidth + hiddenLabelWidth, | ||
| 31 | Number.isFinite(maxWidth) ? maxWidth : Number.POSITIVE_INFINITY, | ||
| 32 | ); | ||
| 33 | return naturalWidth + elementInlineMarginWidth(picker); | ||
| 34 | } | ||
| 35 | |||
| 36 | function providerModelPickerMinimumWidth(picker: HTMLElement): number { | ||
| 37 | const minWidth = Number.parseFloat(getComputedStyle(picker).minWidth) || 0; | ||
| 38 | return minWidth + elementInlineMarginWidth(picker); | ||
| 39 | } | ||
| 40 | |||
| 41 | /** | ||
| 42 | * Read the natural widths of the resting composer controls from the DOM. | ||
| 43 | * | ||
| 44 | * Both the composer (deciding which blocks move into overflow) and the | ||
| 45 | * context strip (deciding whether its labels may expand) read the same | ||
| 46 | * numbers, so neither decision depends on what the other one hid last render. | ||
| 47 | * | ||
| 48 | * Hidden blocks and the unused overflow trigger stay mounted out of flow at | ||
| 49 | * full size. The picker is the one flexible item: its intended width is | ||
| 50 | * recovered from the truncated label. | ||
| 51 | */ | ||
| 52 | export function measureRestingComposerControls( | ||
| 53 | controls: HTMLElement, | ||
| 54 | ): RestingComposerControlsMeasurement | null { | ||
| 55 | const gap = Number.parseFloat(getComputedStyle(controls).columnGap) || 0; | ||
| 56 | const picker = controls.querySelector<HTMLElement>("[data-chat-provider-model-picker]"); | ||
| 57 | const leadingControl = | ||
| 58 | picker ?? controls.querySelector<HTMLElement>('[data-chat-provider-unavailable="true"]'); | ||
| 59 | if (!leadingControl) return null; | ||
| 60 | // Separators are display:none on phone widths; a hidden one takes no gap. | ||
| 61 | const separator = controls.querySelector<HTMLElement>("[data-resting-controls-separator]"); | ||
| 62 | const separatorWidth = separator ? elementOuterWidth(separator) : 0; | ||
| 63 | const overflow = controls.querySelector<HTMLElement>("[data-resting-controls-overflow]"); | ||
| 64 | const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0; | ||
| 65 | const blocks = Array.from(controls.querySelectorAll<HTMLElement>("[data-resting-block]")); | ||
| 66 | return { | ||
| 67 | gap, | ||
| 68 | naturalFixedWidth: | ||
| 69 | (picker ? providerModelPickerNaturalWidth(picker) : elementOuterWidth(leadingControl)) + | ||
| 70 | separatorAndGapWidth, | ||
| 71 | minimumFixedWidth: | ||
| 72 | (picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) + | ||
| 73 | separatorAndGapWidth, | ||
| 74 | blockWidths: blocks.map(elementOuterWidth), | ||
| 75 | overflowWidth: overflow ? elementOuterWidth(overflow) : 0, | ||
| 76 | }; | ||
| 77 | } | ||
apps/web/src/components/composerFooterLayout.test.ts+64| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; | ||
| 3 | import { | 4 | import { |
| 4 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, | 5 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 5 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, | 6 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 6 | getRestingComposerImagePreviewCounts, | 7 | getRestingComposerImagePreviewCounts, |
| 7 | resolveRestingComposerControlsLayout, | 8 | resolveRestingComposerControlsLayout, |
| 9 | resolveRestingComposerControlsNaturalWidth, | ||
| 8 | shouldAnimateComposerRestingTransition, | 10 | shouldAnimateComposerRestingTransition, |
| 9 | shouldUseCompactComposerPrimaryActions, | 11 | shouldUseCompactComposerPrimaryActions, |
| 10 | shouldUseCompactComposerFooter, | 12 | shouldUseCompactComposerFooter, |
| ... | @@ -213,3 +215,65 @@ describe("resolveRestingComposerControlsLayout", () => { | ... | @@ -213,3 +215,65 @@ describe("resolveRestingComposerControlsLayout", () => { |
| 213 | ).toEqual({ hiddenCount: 0, visible: false }); | 215 | ).toEqual({ hiddenCount: 0, visible: false }); |
| 214 | }); | 216 | }); |
| 215 | }); | 217 | }); |
| 218 | |||
| 219 | describe("context strip labels and resting composer controls", () => { | ||
| 220 | // Widths captured from a desktop renderer that crashed with React error | ||
| 221 | // 185. The strip is 724px wide. Its expanded labels need 327px, and the | ||
| 222 | // rest of its chrome needs 125px. The composer controls sit in the host | ||
| 223 | // that takes whatever is left. | ||
| 224 | const stripWidth = 724; | ||
| 225 | const labelWidth = 327; | ||
| 226 | const chromeWidth = 125; | ||
| 227 | const measurement = { | ||
| 228 | gap: 4, | ||
| 229 | naturalFixedWidth: 96.3828125 + 5 + 4, | ||
| 230 | minimumFixedWidth: 52 + 5 + 4, | ||
| 231 | blockWidths: [130.6953125, 119.671875], | ||
| 232 | overflowWidth: 28, | ||
| 233 | }; | ||
| 234 | const naturalWidth = resolveRestingComposerControlsNaturalWidth(measurement); | ||
| 235 | |||
| 236 | function hostWidth(compact: boolean): number { | ||
| 237 | return stripWidth - chromeWidth - (compact ? 0 : labelWidth); | ||
| 238 | } | ||
| 239 | |||
| 240 | it("keeps the labels compact when the full controls only fit beside compact labels", () => { | ||
| 241 | // Compact labels leave 599px, so the composer shows every block. | ||
| 242 | const layout = resolveRestingComposerControlsLayout({ | ||
| 243 | ...measurement, | ||
| 244 | hostWidth: hostWidth(true), | ||
| 245 | }); | ||
| 246 | expect(layout).toEqual({ hiddenCount: 0, visible: true }); | ||
| 247 | |||
| 248 | // The strip reserves the natural controls width, so expanding the | ||
| 249 | // labels is off the table: 125 + 327 + 364 > 724. | ||
| 250 | const compact = resolveContextStripLabelsCompact({ | ||
| 251 | compact: true, | ||
| 252 | neededWidth: chromeWidth + labelWidth + naturalWidth, | ||
| 253 | availableWidth: stripWidth, | ||
| 254 | }); | ||
| 255 | expect(compact).toBe(true); | ||
| 256 | |||
| 257 | // The next pass sees the same inputs and lands on the same answer. | ||
| 258 | expect( | ||
| 259 | resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(compact) }), | ||
| 260 | ).toEqual(layout); | ||
| 261 | }); | ||
| 262 | |||
| 263 | it("does not settle when the strip only reserves the visible controls", () => { | ||
| 264 | // Regression guard for the alternating layout. Reserving only the | ||
| 265 | // controls left visible after two blocks moved into overflow makes the | ||
| 266 | // strip expand its labels, which shrinks the host below what the full | ||
| 267 | // controls need, which hides the blocks again. | ||
| 268 | const hiddenControlsWidth = 137; | ||
| 269 | const expands = !resolveContextStripLabelsCompact({ | ||
| 270 | compact: true, | ||
| 271 | neededWidth: chromeWidth + labelWidth + hiddenControlsWidth, | ||
| 272 | availableWidth: stripWidth, | ||
| 273 | }); | ||
| 274 | expect(expands).toBe(true); | ||
| 275 | expect( | ||
| 276 | resolveRestingComposerControlsLayout({ ...measurement, hostWidth: hostWidth(false) }), | ||
| 277 | ).toEqual({ hiddenCount: 2, visible: true }); | ||
| 278 | }); | ||
| 279 | }); |
apps/web/src/components/composerFooterLayout.ts+47-21| ... | @@ -63,6 +63,43 @@ export function shouldUseCompactComposerPrimaryActions( | ... | @@ -63,6 +63,43 @@ export function shouldUseCompactComposerPrimaryActions( |
| 63 | return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; | 63 | return width !== null && width < COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; |
| 64 | } | 64 | } |
| 65 | 65 | ||
| 66 | export interface RestingComposerControlsMeasurement { | ||
| 67 | gap: number; | ||
| 68 | naturalFixedWidth: number; | ||
| 69 | minimumFixedWidth: number; | ||
| 70 | blockWidths: readonly number[]; | ||
| 71 | overflowWidth: number; | ||
| 72 | } | ||
| 73 | |||
| 74 | function restingComposerControlsWidth( | ||
| 75 | input: RestingComposerControlsMeasurement, | ||
| 76 | hiddenCount: number, | ||
| 77 | fixedWidth = input.naturalFixedWidth, | ||
| 78 | ): number { | ||
| 79 | const { blockWidths, gap } = input; | ||
| 80 | const visibleCount = blockWidths.length - hiddenCount; | ||
| 81 | return ( | ||
| 82 | fixedWidth + | ||
| 83 | blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + | ||
| 84 | (hiddenCount > 0 ? input.overflowWidth : 0) + | ||
| 85 | gap * (visibleCount + (hiddenCount > 0 ? 1 : 0)) | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | |||
| 89 | /** | ||
| 90 | * The width the resting controls take with nothing moved into overflow. | ||
| 91 | * | ||
| 92 | * The context strip reserves this much for the composer before deciding | ||
| 93 | * whether its own labels may expand. Judging against the currently visible | ||
| 94 | * controls instead lets the strip expand into space the composer just gave | ||
| 95 | * up, which shrinks the host, hides the controls again, and repeats. | ||
| 96 | */ | ||
| 97 | export function resolveRestingComposerControlsNaturalWidth( | ||
| 98 | input: RestingComposerControlsMeasurement, | ||
| 99 | ): number { | ||
| 100 | return restingComposerControlsWidth(input, 0); | ||
| 101 | } | ||
| 102 | |||
| 66 | /** | 103 | /** |
| 67 | * Decide how many trailing resting control blocks move into the overflow | 104 | * Decide how many trailing resting control blocks move into the overflow |
| 68 | * menu, and whether the cluster can show at all, from natural widths. | 105 | * menu, and whether the cluster can show at all, from natural widths. |
| ... | @@ -71,29 +108,18 @@ export function shouldUseCompactComposerPrimaryActions( | ... | @@ -71,29 +108,18 @@ export function shouldUseCompactComposerPrimaryActions( |
| 71 | * the overflow menu, the picker may contract to its minimum readable width; | 108 | * the overflow menu, the picker may contract to its minimum readable width; |
| 72 | * below that the whole cluster hides rather than clipping. | 109 | * below that the whole cluster hides rather than clipping. |
| 73 | */ | 110 | */ |
| 74 | export function resolveRestingComposerControlsLayout(input: { | 111 | export function resolveRestingComposerControlsLayout( |
| 75 | hostWidth: number; | 112 | input: RestingComposerControlsMeasurement & { hostWidth: number }, |
| 76 | gap: number; | 113 | ): { hiddenCount: number; visible: boolean } { |
| 77 | naturalFixedWidth: number; | 114 | const { blockWidths, hostWidth } = input; |
| 78 | minimumFixedWidth: number; | ||
| 79 | blockWidths: readonly number[]; | ||
| 80 | overflowWidth: number; | ||
| 81 | }): { hiddenCount: number; visible: boolean } { | ||
| 82 | const { blockWidths, gap, hostWidth } = input; | ||
| 83 | const widthWithHidden = (hidden: number, fixedWidth = input.naturalFixedWidth) => { | ||
| 84 | const visibleCount = blockWidths.length - hidden; | ||
| 85 | return ( | ||
| 86 | fixedWidth + | ||
| 87 | blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) + | ||
| 88 | (hidden > 0 ? input.overflowWidth : 0) + | ||
| 89 | gap * (visibleCount + (hidden > 0 ? 1 : 0)) | ||
| 90 | ); | ||
| 91 | }; | ||
| 92 | |||
| 93 | let hiddenCount = 0; | 115 | let hiddenCount = 0; |
| 94 | while (hiddenCount < blockWidths.length && widthWithHidden(hiddenCount) > hostWidth) { | 116 | while ( |
| 117 | hiddenCount < blockWidths.length && | ||
| 118 | restingComposerControlsWidth(input, hiddenCount) > hostWidth | ||
| 119 | ) { | ||
| 95 | hiddenCount += 1; | 120 | hiddenCount += 1; |
| 96 | } | 121 | } |
| 97 | const visible = widthWithHidden(hiddenCount, input.minimumFixedWidth) <= hostWidth; | 122 | const visible = |
| 123 | restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth) <= hostWidth; | ||
| 98 | return { hiddenCount, visible }; | 124 | return { hiddenCount, visible }; |
| 99 | } | 125 | } |