Skip to content
Source
Sign inSign up
Open History to see the last commit

ComposerFooterControls.tsx

331 lines13.1 KB

1import type { ModelSelection, ProviderInstanceId, RuntimeMode } from "@t3tools/contracts";
2import { createModelSelection } from "@t3tools/shared/model";
3import { type ComponentProps, memo, useId, useRef, useState } from "react";
4​
5import { Menu as MenuPrimitive } from "@base-ui/react/menu";
6import { Menu, MenuPopup, MenuTrigger } from "../ui/menu";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8import { ComposerControl, ComposerControlIcon } from "./ComposerControl";
9import { focusRequestedPermission, useComposerMenuProps } from "./composerEventScope";
10import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
11import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
12import { ProviderModelPicker } from "./ProviderModelPicker";
13import { runtimeModeConfig } from "./runtimeModeConfig";
14import { useComposerMenuState } from "./useComposerMenuState";
15​
16export type RuntimeModeOption = {
17 mode: RuntimeMode;
18 disabledReason?: string;
19} & (typeof runtimeModeConfig)[RuntimeMode];
20const supervisedRuntimeModeOption: RuntimeModeOption = {
21 mode: "approval-required" as const,
22 ...runtimeModeConfig["approval-required"],
23};
24export const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
25 runtimeMode: RuntimeMode;
26 runtimeModeOptions: ReadonlyArray<RuntimeModeOption>;
27 size?: "sm" | "xs";
28 hidden?: boolean;
29 /** How the permissions chip shows: hidden while the thread matches the user's default. */
30 accessDisplay?: "label" | "icon" | "hidden";
31 /** Draw the icon in the label color, not muted (the mode differs from the user's default). */
32 emphasized?: boolean;
33 onRuntimeModeChange: (mode: RuntimeMode) => void;
34}) {
35 const size = props.size ?? "sm";
36 const accessDisplay = props.accessDisplay ?? "label";
37 const composerFloatingLayerProps = useComposerMenuProps();
38 const [open, setOpen] = useComposerMenuState(props.hidden);
39 const permissionsChipRef = useRef<HTMLButtonElement>(null);
40 const runtimeModeOption =
41 props.runtimeModeOptions.find((option) => option.mode === props.runtimeMode) ??
42 supervisedRuntimeModeOption;
43 const RuntimeModeIcon = runtimeModeOption.icon;
44 // The chip opens the same Permissions toggle used in the model picker and the overflow menu.
45 const permissionsTooltip = `Permissions: ${runtimeModeOption.label}. ${runtimeModeOption.description}`;
46 const permissionsChip = (
47 <Menu
48 open={open}
49 onOpenChange={setOpen}
50 onOpenChangeComplete={(isOpen) =>
51 focusRequestedPermission(isOpen, permissionsChipRef.current)
52 }
53 >
54 <Tooltip>
55 <TooltipTrigger
56 render={
57 <MenuTrigger
58 render={
59 <ComposerControl
60 ref={permissionsChipRef}
61 data-composer-shortcut="composer.mode"
62 size={size}
63 // Mirrors the model trigger's trailing pe-1 so the gaps on both sides of the chip match.
64 className="ps-1.5"
65 aria-label={`Permissions: ${runtimeModeOption.label}`}
66 />
67 }
68 />
69 }
70 >
71 <ComposerControlIcon
72 icon={RuntimeModeIcon}
73 size={size}
74 // Match the label, not full foreground: the chip's presence already marks the mode.
75 {...(props.emphasized ? { className: "text-current" } : {})}
76 />
77 <span
78 data-composer-control-label
79 className={accessDisplay === "icon" ? "sr-only" : undefined}
80 >
81 {runtimeModeOption.label}
82 </span>
83 </TooltipTrigger>
84 <TooltipPopup side="top">{permissionsTooltip}</TooltipPopup>
85 </Tooltip>
86 <MenuPopup align="start" {...composerFloatingLayerProps}>
87 <ComposerPermissionsSection
88 runtimeMode={props.runtimeMode}
89 runtimeModeOptions={props.runtimeModeOptions}
90 onRuntimeModeChange={props.onRuntimeModeChange}
91 />
92 </MenuPopup>
93 </Menu>
94 );
95​
96 return accessDisplay === "hidden" ? null : permissionsChip;
97});
98​
99/**
100 * Permission modes as one horizontal toggle, with the shown mode named below.
101 * Each segment is a real menu radio item, so arrow keys reach it from the rest of the menu
102 * and a pick closes the menu. Lives inside a Menu: the permissions chip, the model picker,
103 * or the overflow menu.
104 */
105export function ComposerPermissionsSection(props: {
106 runtimeMode: RuntimeMode;
107 runtimeModeOptions: ReadonlyArray<RuntimeModeOption>;
108 onRuntimeModeChange: (mode: RuntimeMode) => void;
109}) {
110 // Hover or keyboard focus previews a mode's name and description before a pick.
111 const [previewMode, setPreviewMode] = useState<RuntimeMode | null>(null);
112 const shownMode = previewMode ?? props.runtimeMode;
113 const shown =
114 props.runtimeModeOptions.find((option) => option.mode === shownMode) ??
115 supervisedRuntimeModeOption;
116 const descriptionId = useId();
117 return (
118 <div data-composer-permissions className="grid w-72 gap-1.5 px-2 py-1.5">
119 <div className="font-medium text-muted-foreground text-xs">Permissions</div>
120 <MenuPrimitive.RadioGroup
121 aria-label="Permissions"
122 value={props.runtimeMode}
123 onValueChange={(value: RuntimeMode) => {
124 if (value !== props.runtimeMode) props.onRuntimeModeChange(value);
125 }}
126 className="flex rounded-lg bg-input/40 p-0.5"
127 // Menus navigate with Up/Down only; the row reads horizontally, so Left/Right move too.
128 onKeyDown={(event) => {
129 if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
130 const items = Array.from(
131 event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
132 );
133 const index = items.findIndex((item) => item === document.activeElement);
134 const next = items[index + (event.key === "ArrowRight" ? 1 : -1)];
135 if (index < 0 || !next) return;
136 event.preventDefault();
137 event.stopPropagation();
138 next.focus();
139 }}
140 >
141 {props.runtimeModeOptions.map((option) => {
142 const OptionIcon = option.icon;
143 return (
144 <MenuPrimitive.RadioItem
145 key={option.mode}
146 value={option.mode}
147 disabled={option.disabledReason !== undefined}
148 label={option.label}
149 aria-label={option.label}
150 aria-describedby={`${descriptionId}-${option.mode}`}
151 closeOnClick
152 onPointerEnter={() => setPreviewMode(option.mode)}
153 onPointerLeave={() => setPreviewMode(null)}
154 onFocus={() => setPreviewMode(option.mode)}
155 onBlur={() => setPreviewMode(null)}
156 className="flex h-6 flex-1 cursor-pointer items-center justify-center text-muted-foreground outline-none first:rounded-l-md last:rounded-r-md data-disabled:cursor-not-allowed data-disabled:opacity-40 data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
157 >
158 <OptionIcon aria-hidden="true" className="size-3.5" />
159 </MenuPrimitive.RadioItem>
160 );
161 })}
162 </MenuPrimitive.RadioGroup>
163 {/* Static per-mode descriptions for screen readers; the preview below is visual only. */}
164 {props.runtimeModeOptions.map((option) => (
165 <span key={option.mode} id={`${descriptionId}-${option.mode}`} className="sr-only">
166 {option.disabledReason ?? option.description}
167 </span>
168 ))}
169 <div className="grid gap-0.5" aria-hidden="true">
170 <span
171 className={
172 shownMode === props.runtimeMode
173 ? "text-foreground text-sm"
174 : "text-muted-foreground text-sm"
175 }
176 >
177 {shown.label}
178 </span>
179 {/* Two lines reserved so the menu does not shift while you preview modes. */}
180 <span className="min-h-8 text-muted-foreground text-xs leading-4">
181 {shown.disabledReason ?? shown.description}
182 </span>
183 </div>
184 </div>
185 );
187​
188export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
189 children?: React.ReactNode;
190 compact: boolean;
191 canOperateThread: boolean;
192 isPreparingWorktree: boolean;
193 pendingAction: {
194 questionIndex: number;
195 isLastQuestion: boolean;
196 canAdvance: boolean;
197 isResponding: boolean;
198 isComplete: boolean;
199 } | null;
200 isRunning: boolean;
201 canInterrupt: boolean;
202 runId: string | null;
203 followUpBehavior: "queue" | "steer";
204 canSteer: boolean;
205 alternateShortcutLabel: string | null;
206 voiceInputActive?: boolean;
207 isSendBusy: boolean;
208 sendDisabledReason: string | null;
209 isEnvironmentUnavailable: boolean;
210 hasSendableContent: boolean;
211 canResume: boolean;
212 preserveComposerFocusOnPointerDown?: boolean;
213 isEditingQueuedMessage: boolean;
214 onSubmitMessage: React.MouseEventHandler<HTMLButtonElement>;
215 onResume: () => void;
216 onPreviousPendingQuestion: () => void;
217 onInterrupt: () => void;
218 compactBeforeSendTokens: number | null;
219 hideCompactToggle: boolean;
220 keepFullHistory: boolean;
221 onToggleKeepFullHistory: () => void;
222}) {
223 return (
224 <>
225 <ComposerPrimaryActions
226 compact={props.compact}
227 canOperateThread={props.canOperateThread}
228 pendingAction={props.pendingAction}
229 isRunning={props.isRunning}
230 canInterrupt={props.canInterrupt}
231 runId={props.runId}
232 followUpBehavior={props.followUpBehavior}
233 canSteer={props.canSteer}
234 alternateShortcutLabel={props.alternateShortcutLabel}
235 voiceInputActive={props.voiceInputActive ?? false}
236 isSendBusy={props.isSendBusy}
237 sendDisabledReason={props.sendDisabledReason}
238 isEnvironmentUnavailable={props.isEnvironmentUnavailable}
239 isPreparingWorktree={props.isPreparingWorktree}
240 hasSendableContent={props.hasSendableContent}
241 canResume={props.canResume}
242 preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
243 isEditingQueuedMessage={props.isEditingQueuedMessage}
244 onSubmitMessage={props.onSubmitMessage}
245 onResume={props.onResume}
246 onPreviousPendingQuestion={props.onPreviousPendingQuestion}
247 onInterrupt={props.onInterrupt}
248 compactBeforeSendTokens={props.compactBeforeSendTokens}
249 hideCompactToggle={props.hideCompactToggle}
250 keepFullHistory={props.keepFullHistory}
251 onToggleKeepFullHistory={props.onToggleKeepFullHistory}
252 >
253 {props.children}
254 </ComposerPrimaryActions>
255 </>
256 );
257});
258​
259/**
260 * The composer's model picker. It owns the single- and multi-model selection
261 * handlers so they are cached against the selection state, not rebuilt on
262 * every ChatComposer render.
263 */
264export function ComposerModelPicker({
265 multipleModelsEnabled,
266 multipleModelSelections,
267 onMultipleModelSelectionsChange,
268 selectedModelSelection,
269 onProviderModelSelect,
270 ...pickerProps
271}: Omit<
272 ComponentProps<typeof ProviderModelPicker>,
273 "selectedModels" | "onToggleModel" | "onInstanceModelChange"
274> & {
275 readonly multipleModelsEnabled: boolean;
276 readonly multipleModelSelections: ReadonlyArray<ModelSelection> | null;
277 readonly onMultipleModelSelectionsChange: (
278 selections: ReadonlyArray<ModelSelection> | null,
279 ) => void;
280 readonly selectedModelSelection: ModelSelection;
281 readonly onProviderModelSelect: (
282 instanceId: ProviderInstanceId,
283 model: string,
284 options?: { focusComposer?: boolean },
285 ) => void;
286}) {
287 const { instanceEntries, modelOptionsByInstance } = pickerProps;
288 const toggleModel = (instanceId: ProviderInstanceId, model: string) => {
289 const current = multipleModelSelections ?? [selectedModelSelection];
290 const matchesModel = (selection: ModelSelection) => {
291 if (selection.instanceId !== instanceId) return false;
292 const entry = instanceEntries.find((entry) => entry.instanceId === selection.instanceId);
293 const resolvedModel = resolveModelPickerSelectedModel({
294 driverKind: entry?.driverKind,
295 model: selection.model,
296 options: modelOptionsByInstance?.get(selection.instanceId) ?? [],
297 });
298 return (resolvedModel?.slug ?? selection.model) === model;
299 };
300 const exists = current.some(matchesModel);
301 const next = exists
302 ? current.filter((selection) => !matchesModel(selection))
303 : [...current, createModelSelection(instanceId, model)];
304 if (next.length > 1) {
305 onMultipleModelSelectionsChange(next);
306 } else {
307 onMultipleModelSelectionsChange(null);
308 const remaining = next[0] ?? selectedModelSelection;
309 onProviderModelSelect(remaining.instanceId, remaining.model, {
310 focusComposer: false,
311 });
312 }
313 };
314 return (
315 <ProviderModelPicker
316 {...pickerProps}
317 {...(multipleModelsEnabled
318 ? {
319 ...(multipleModelSelections !== null
320 ? { selectedModels: multipleModelSelections }
321 : {}),
322 onToggleModel: toggleModel,
323 }
324 : {})}
325 onInstanceModelChange={(instanceId, model) => {
326 onMultipleModelSelectionsChange(null);
327 onProviderModelSelect(instanceId, model);
328 }}
329 />
330 );