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: ( -