Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import { serializeComposerImages } from "./composerImagePersistence";
import {
isInsideCollapsedComposerControls,
isInsideRestingComposerControlScope,
markOpenToEffort,
markOpenToPermissions,
} from "./composerEventScope";
import {
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 2 additions & 18 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,7 +19,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
runtimeMode: RuntimeMode;
runtimeModeOptions: ReadonlyArray<RuntimeModeOption>;
showInteractionModeToggle: boolean;
traitsMenuContent?: ReactNode;
showPermissions?: boolean;
size?: "sm" | "xs";
/** Close the portaled popup when its trigger hides. */
Expand All @@ -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}
/>
}
>
<ComposerControlIcon icon={EllipsisIcon} size={size} />
</MenuTrigger>
<MenuPopup align="start" {...composerFloatingLayerProps}>
{props.traitsMenuContent ? (
<>
{props.traitsMenuContent}
{props.showInteractionModeToggle || props.showPermissions !== false ? (
<MenuDivider />
) : null}
</>
) : null}
{props.showInteractionModeToggle ? (
<>
<div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>
Expand Down
171 changes: 64 additions & 107 deletions apps/web/src/components/chat/ComposerControlsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { CircleAlertIcon } from "lucide-react";
import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
import { ComposerControl } from "./ComposerControl";
import {
ComposerPermissionsSection,
ComposerFooterModeControls,
ComposerModelPicker,
type RuntimeModeOption,
Expand All @@ -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";

Expand Down Expand Up @@ -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: (
<ComposerPermissionsSection
runtimeMode={compatibleRuntimeMode}
runtimeModeOptions={compatibleRuntimeModeOptions}
onRuntimeModeChange={handleRuntimeModeChange}
/>
),
...(permissionsChipHidden
? {
extraMenuHint: [
`Permissions: ${
compatibleRuntimeModeOptions.find((option) => option.mode === compatibleRuntimeMode)
?.label ?? compatibleRuntimeMode
}`,
shortcutLabelForCommand(keybindings, "composer.mode"),
]
.filter(Boolean)
.join(" · "),
}
: {}),
} satisfies Parameters<typeof renderProviderTraitsPicker>[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: (
<ComposerFooterModeControls
showInteractionModeToggle={planModeUiEnabled}
interactionMode={interactionMode}
runtimeMode={compatibleRuntimeMode}
runtimeModeOptions={compatibleRuntimeModeOptions}
size={composerControlsCollapsed ? "xs" : "sm"}
hidden={composerControlsHidden || restingHiddenBlockCount > 0}
accessDisplay={accessDisplay}
emphasized={!accessMatchesDefault}
onToggleInteractionMode={toggleInteractionMode}
onRuntimeModeChange={handleRuntimeModeChange}
/>
),
},
]),
];
const effort = describeComposerEffort(traitsInput, props.effortDisplay, controlSize);
const restingBlockDefs = showModeBlock
? [
{
id: "mode",
content: (
<ComposerFooterModeControls
showInteractionModeToggle={planModeUiEnabled}
interactionMode={interactionMode}
runtimeMode={compatibleRuntimeMode}
runtimeModeOptions={compatibleRuntimeModeOptions}
size={controlSize}
hidden={composerControlsHidden || restingHiddenBlockCount > 0}
accessDisplay={accessDisplay}
emphasized={!accessMatchesDefault}
onToggleInteractionMode={toggleInteractionMode}
onRuntimeModeChange={handleRuntimeModeChange}
/>
),
},
]
: [];
// Its own element, so typing (which rebuilds the controls around it) leaves it cached.
const composerModelPicker = (
<ComposerModelPicker
Expand Down Expand Up @@ -269,12 +215,26 @@ export function ComposerControlsBar(props: {
instanceEntries={providerInstanceEntries}
keybindings={keybindings}
modelOptionsByInstance={modelOptionsByInstance}
size={composerControlsCollapsed ? "xs" : "sm"}
size={controlSize}
triggerClassName={cn(
"pe-1",
"group/model-trigger pe-1",
TEXT_CONTROL_STATES,
composerControlsCollapsed ? "min-w-13 shrink text-xs!" : "min-w-13",
)}
triggerSuffix={effort.suffix}
triggerDetail={effort.detail}
triggerShortcuts={
pickerOwnsModeShortcut ? "composer.effort composer.mode" : "composer.effort"
}
effortColumn={<ComposerEffortColumn traits={traitsInput} />}
footer={
<ComposerPickerFooter
traits={traitsInput}
runtimeMode={compatibleRuntimeMode}
runtimeModeOptions={compatibleRuntimeModeOptions}
onRuntimeModeChange={handleRuntimeModeChange}
/>
}
terminalOpen={terminalOpen}
open={modelPickerOpen}
instanceIndicatorBackground={
Expand Down Expand Up @@ -345,12 +305,9 @@ export function ComposerControlsBar(props: {
interactionMode={interactionMode}
runtimeMode={compatibleRuntimeMode}
runtimeModeOptions={compatibleRuntimeModeOptions}
size={composerControlsCollapsed ? "xs" : "sm"}
size={controlSize}
hidden={composerControlsHidden || hiddenRestingBlockIds.length === 0}
showInteractionModeToggle={planModeUiEnabled && hiddenRestingBlockIds.includes("mode")}
traitsMenuContent={
hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined
}
showInteractionModeToggle={planModeUiEnabled && hiddenRestingBlockIds.length > 0}
// Permissions join the overflow only when their normal home is in it.
showPermissions={overflowShowsPermissions}
onToggleInteractionMode={toggleInteractionMode}
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/ComposerFooterControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const ComposerFooterModeControls = memo(function ComposerFooterModeContro
</Tooltip>
) : null;

// The chip opens the same Permissions toggle used in the effort menu.
// The chip opens the same Permissions toggle used in the model picker and the overflow menu.
const permissionsTooltip = `Permissions: ${runtimeModeOption.label}. ${runtimeModeOption.description}`;
const permissionsChip = (
<Menu
Expand All @@ -112,7 +112,7 @@ export const ComposerFooterModeControls = memo(function ComposerFooterModeContro
ref={permissionsChipRef}
data-composer-shortcut="composer.mode"
size={size}
// Mirrors the effort chip's trailing pe-1 so both gaps around the bars match.
// Mirrors the model trigger's trailing pe-1 so the gaps on both sides of the chip match.
className="ps-1.5"
aria-label={`Permissions: ${runtimeModeOption.label}`}
/>
Expand Down Expand Up @@ -156,7 +156,7 @@ export const ComposerFooterModeControls = memo(function ComposerFooterModeContro
/**
* Permission modes as one horizontal toggle, with the shown mode named below.
* Each segment is a real menu radio item, so arrow keys reach it from the rest of the menu
* and a pick closes the menu. Lives inside a Menu: the permissions chip, the effort menu,
* and a pick closes the menu. Lives inside a Menu: the permissions chip, the model picker,
* or the overflow menu.
*/
export function ComposerPermissionsSection(props: {
Expand Down
Loading
Loading