From 7ef6d7c8c0bca6dc654afe54f76332340c092b11 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 01:24:02 -0700 Subject: [PATCH 1/6] feat(web): model and effort share one control The composer showed the model and the effort level as two click targets that read as one label. One trigger now opens the model picker with the effort levels docked beside the list and speed and permissions in a footer under both. Picker rows are one line with a leading provider logo in every tab, so Favorites matches the provider tabs. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 2 + .../chat/CompactComposerControlsMenu.tsx | 20 +- .../components/chat/ComposerControlsBar.tsx | 171 +++---- .../chat/ComposerFooterControls.tsx | 6 +- .../components/chat/ComposerModelEffort.tsx | 409 ++++++++++++++++ ...omposerPermissionsSection.browser.test.tsx | 40 +- apps/web/src/components/chat/EffortGlyph.tsx | 19 + apps/web/src/components/chat/ModelListRow.tsx | 82 ++-- .../components/chat/ModelPickerContent.tsx | 69 ++- .../chat/ProviderModelPicker.browser.test.tsx | 77 +++ .../components/chat/ProviderModelPicker.tsx | 178 +++++-- apps/web/src/components/chat/TraitsPicker.tsx | 452 ++++++------------ .../chat/composerControlsOwnership.test.ts | 43 +- .../chat/composerControlsOwnership.ts | 61 +-- .../src/components/chat/composerEventScope.ts | 29 +- .../components/chat/composerProviderState.tsx | 116 +---- .../src/components/chat/providerIconUtils.ts | 15 - apps/web/src/components/ui/combobox.tsx | 6 +- docs/user/keybindings.md | 3 +- docs/user/permission-modes.md | 4 +- docs/user/providers-acp.md | 2 +- docs/user/providers-antigravity.md | 2 +- 22 files changed, 1049 insertions(+), 757 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerModelEffort.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6e2455bb340ba..0cbdc4e3c5bee 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -92,6 +92,7 @@ import { serializeComposerImages } from "./composerImagePersistence"; import { isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, + markOpenToEffort, markOpenToPermissions, } from "./composerEventScope"; import { @@ -2874,6 +2875,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!trigger) return; trigger.focus({ preventScroll: true }); if (command === "composer.mode") markOpenToPermissions(trigger); + if (command === "composer.effort") markOpenToEffort(trigger); trigger.click(); }, compactContext: compactThreadContextStable, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 68ea2036b1667..e5fbddf56c769 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,5 +1,5 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; -import { memo, type ReactNode, useRef } from "react"; +import { memo, useRef } from "react"; import { EllipsisIcon } from "lucide-react"; import { Menu, @@ -19,7 +19,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls runtimeMode: RuntimeMode; runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; - traitsMenuContent?: ReactNode; showPermissions?: boolean; size?: "sm" | "xs"; /** Close the portaled popup when its trigger hides. */ @@ -45,28 +44,13 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls size={size} className="shrink-0" aria-label="More composer controls" - data-composer-shortcut={ - [ - props.showPermissions !== false ? "composer.mode" : null, - props.traitsMenuContent ? "composer.effort" : null, - ] - .filter(Boolean) - .join(" ") || undefined - } + data-composer-shortcut={props.showPermissions !== false ? "composer.mode" : undefined} /> } > - {props.traitsMenuContent ? ( - <> - {props.traitsMenuContent} - {props.showInteractionModeToggle || props.showPermissions !== false ? ( - - ) : null} - - ) : null} {props.showInteractionModeToggle ? ( <>
Mode
diff --git a/apps/web/src/components/chat/ComposerControlsBar.tsx b/apps/web/src/components/chat/ComposerControlsBar.tsx index 4dd547393ff74..ed2bef35caea5 100644 --- a/apps/web/src/components/chat/ComposerControlsBar.tsx +++ b/apps/web/src/components/chat/ComposerControlsBar.tsx @@ -6,7 +6,6 @@ import { CircleAlertIcon } from "lucide-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerControl } from "./ComposerControl"; import { - ComposerPermissionsSection, ComposerFooterModeControls, ComposerModelPicker, type RuntimeModeOption, @@ -22,19 +21,21 @@ import { } from "@t3tools/contracts"; import type { EffortDisplay } from "@t3tools/contracts/settings"; import { cn } from "~/lib/utils"; -import { shortcutLabelForCommand } from "../../keybindings"; +import { resolveComposerTraitsInput } from "./composerProviderState"; import { - renderProviderTraitsMenuContent, - renderProviderTraitsPicker, -} from "./composerProviderState"; + ComposerEffortColumn, + ComposerPickerFooter, + describeComposerEffort, +} from "./ComposerModelEffort"; import type { useComposerProviderModel } from "./useComposerProviderModel"; import { resolveComposerControlsOwnership } from "./composerControlsOwnership"; /** - * The composer footer controls: model picker, traits, mode controls and the overflow menu for - * blocks that do not fit. Rendered in the expanded footer or the resting controls host. + * The composer footer controls: the model picker (which also holds effort, speed and + * permissions), mode controls and the overflow menu for blocks that do not fit. Rendered in the + * expanded footer or the resting controls host. */ -// Model and effort sit a word space apart, so hover and open states brighten text, not a box. +// Model and effort share one trigger; hover and open states brighten its text, not a box. const TEXT_CONTROL_STATES = "hover:bg-transparent data-pressed:bg-transparent data-popup-open:bg-transparent data-pressed:text-foreground data-popup-open:text-foreground"; @@ -137,111 +138,56 @@ export function ComposerControlsBar(props: { modelPickerIconClassName, getModelDisabledReason, } = props; - const providerTraitsMenuContent = renderProviderTraitsMenuContent({ - provider: selectedProvider, - instanceId: selectedInstanceId, - ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), - ...(routeKind === "draft" && draftId ? { draftId } : {}), - model: selectedModel, - models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedInstanceId], - reportedModelSelection, - promptUltrathink, - onPromptEdit, - planModeEnabled, - }); const accessMatchesDefault = compatibleRuntimeMode === defaultRuntimeMode; - const permissionsChipHidden = accessMatchesDefault || props.permissionsShownInStrip; - const providerTraitsPickerInput = { - provider: selectedProvider, - instanceId: selectedInstanceId, - ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), - ...(routeKind === "draft" && draftId ? { draftId } : {}), - model: selectedModel, - models: selectedProviderModels, - modelOptions: composerModelOptions?.[selectedInstanceId], - reportedModelSelection, - promptUltrathink, - onPromptEdit, - planModeEnabled, - isComposerOwned: true, - effortDisplay: props.effortDisplay, - triggerClassName: cn("ps-1.5 pe-1", TEXT_CONTROL_STATES), - // Permissions always live in the effort menu, so the section never unmounts while open. - extraMenuContent: ( - - ), - ...(permissionsChipHidden - ? { - extraMenuHint: [ - `Permissions: ${ - compatibleRuntimeModeOptions.find((option) => option.mode === compatibleRuntimeMode) - ?.label ?? compatibleRuntimeMode - }`, - shortcutLabelForCommand(keybindings, "composer.mode"), - ] - .filter(Boolean) - .join(" · "), - } - : {}), - } satisfies Parameters[0]; - const providerTraitsPicker = renderProviderTraitsPicker(providerTraitsPickerInput); const { accessDisplay, showModeBlock, hiddenBlockCount: restingHiddenBlockCount, hiddenBlockIds: hiddenRestingBlockIds, - traitsHidden: traitsBlockHidden, - effortOwnsModeShortcut, overflowShowsPermissions, + pickerOwnsModeShortcut, } = resolveComposerControlsOwnership({ - matchesDefault: permissionsChipHidden, - hasTraits: providerTraitsPicker !== null, + matchesDefault: accessMatchesDefault || props.permissionsShownInStrip, planModeUiEnabled, collapsed: composerControlsCollapsed, measuredHiddenBlockCount, }); - const restingProviderTraitsPicker = renderProviderTraitsPicker({ - ...providerTraitsPickerInput, - size: composerControlsCollapsed ? "xs" : "sm", - hidden: composerControlsHidden || traitsBlockHidden, - extraMenuOwnsModeShortcut: effortOwnsModeShortcut, + const controlSize = composerControlsCollapsed ? "xs" : "sm"; + const traitsInput = resolveComposerTraitsInput({ + provider: selectedProvider, + instanceId: selectedInstanceId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), + model: selectedModel, + models: selectedProviderModels, + modelOptions: composerModelOptions?.[selectedInstanceId], + reportedModelSelection, + promptUltrathink, + onPromptEdit, + planModeEnabled, }); - const restingBlockDefs = [ - ...(providerTraitsPicker - ? [ - { - id: "traits", - content: restingProviderTraitsPicker, - }, - ] - : []), - ...(!showModeBlock - ? [] - : [ - { - id: "mode", - content: ( -