diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index c86380f8..bffe323e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -9165,68 +9165,101 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps one composer row and moves stash into the overflow menu in an extra-narrow pane", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: fixture.snapshot, - }); - - try { - await mounted.setContainerSize({ - width: 250, - height: WIDE_FOOTER_VIEWPORT.height, + // Real phone viewports (touch-size buttons) and extra-narrow desktop side + // panes. The stash bookmark used to hide in the overflow menu below the + // compact breakpoint; now it stays in the row, so the row must still fit + // without the bookmark shoving the overflow trigger off-screen or squeezing + // the model picker down to nothing. One test per viewport: the full app + // only mounts cleanly once per test. + it.each([ + { name: "phone", viewport: PHONE_VIEWPORT, width: PHONE_VIEWPORT.width }, + { name: "small phone", viewport: { ...PHONE_VIEWPORT, width: 360 }, width: 360 }, + { name: "narrow pane", viewport: WIDE_FOOTER_VIEWPORT, width: 320 }, + { name: "extra-narrow pane", viewport: WIDE_FOOTER_VIEWPORT, width: 250 }, + ])( + "keeps one composer row with the stash bookmark and overflow menu both visible ($name)", + async ({ viewport, width }) => { + const mounted = await mountChatView({ + viewport, + // The shared fixture snapshot does not render at phone viewports in + // this harness; the target-user snapshot does. + snapshot: + viewport === WIDE_FOOTER_VIEWPORT + ? fixture.snapshot + : createSnapshotForTargetUser({ + targetMessageId: "msg-user-narrow-footer" as MessageId, + targetText: "narrow footer", + }), }); - const footer = await waitForElement( - findVisibleComposerFooter, - "Unable to find composer footer.", - ); - const leftActions = await waitForElement( - () => footer.querySelector('[data-chat-composer-actions="left"]'), - "Unable to find left composer actions.", - ); - const rightActions = await waitForElement( - () => footer.querySelector('[data-chat-composer-actions="right"]'), - "Unable to find right composer actions.", - ); - const moreControlsButton = await waitForElement( - () => - footer.querySelector('button[aria-label="More composer controls"]'), - "Unable to find compact composer controls.", - ); + try { + await mounted.setContainerSize({ + width, + height: viewport.height, + }); - await vi.waitFor( - () => { - const footerRect = footer.getBoundingClientRect(); - const leftRect = leftActions.getBoundingClientRect(); - const rightRect = rightActions.getBoundingClientRect(); - - const leftCenter = (leftRect.top + leftRect.bottom) / 2; - const rightCenter = (rightRect.top + rightRect.bottom) / 2; - expect(Math.abs(leftCenter - rightCenter)).toBeLessThanOrEqual(1); - expect(moreControlsButton.getBoundingClientRect().right).toBeLessThanOrEqual( - leftRect.right + 0.5, - ); - expect(leftRect.right).toBeLessThanOrEqual(rightRect.left + 0.5); - expect(rightRect.right).toBeLessThanOrEqual(footerRect.right + 0.5); - expect(footer.scrollWidth).toBeLessThanOrEqual(footer.clientWidth + 1); - expect(footer.dataset.chatComposerFooterCompact).toBe("true"); - }, - { timeout: 8_000, interval: 16 }, - ); + const footer = await waitForElement( + findVisibleComposerFooter, + "Unable to find composer footer.", + ); + const leftActions = await waitForElement( + () => footer.querySelector('[data-chat-composer-actions="left"]'), + "Unable to find left composer actions.", + ); + const rightActions = await waitForElement( + () => footer.querySelector('[data-chat-composer-actions="right"]'), + "Unable to find right composer actions.", + ); + const moreControlsButton = await waitForElement( + () => + footer.querySelector('button[aria-label="More composer controls"]'), + "Unable to find compact composer controls.", + ); + const stashButton = await waitForElement( + () => + rightActions.querySelector('button[aria-label^="Stashed prompts"]'), + "Unable to find the stash bookmark in the composer row.", + ); + const modelButton = await waitForElement( + () => + leftActions.querySelector( + '[data-chat-provider-model-picker="true"]', + ), + "Unable to find provider model picker.", + ); - expect(document.querySelector('button[aria-label^="Stashed prompts"]')).toBeNull(); - moreControlsButton.click(); - await waitForElement( - () => document.querySelector('[data-slot="menu-sub-trigger"]'), - "Unable to find the stashed prompts submenu.", - ); - } finally { - await mounted.cleanup(); - } - }); + await vi.waitFor( + () => { + const footerRect = footer.getBoundingClientRect(); + const leftRect = leftActions.getBoundingClientRect(); + const rightRect = rightActions.getBoundingClientRect(); + const moreRect = moreControlsButton.getBoundingClientRect(); + const stashRect = stashButton.getBoundingClientRect(); + + const leftCenter = (leftRect.top + leftRect.bottom) / 2; + const rightCenter = (rightRect.top + rightRect.bottom) / 2; + expect(Math.abs(leftCenter - rightCenter)).toBeLessThanOrEqual(1); + expect(moreRect.left).toBeGreaterThanOrEqual(leftRect.left - 0.5); + expect(moreRect.right).toBeLessThanOrEqual(leftRect.right + 0.5); + expect(leftRect.right).toBeLessThanOrEqual(rightRect.left + 0.5); + expect(stashRect.left).toBeGreaterThanOrEqual(rightRect.left - 0.5); + expect(stashRect.right).toBeLessThanOrEqual(rightRect.right + 0.5); + expect(rightRect.right).toBeLessThanOrEqual(footerRect.right + 0.5); + expect(footer.scrollWidth).toBeLessThanOrEqual(footer.clientWidth + 1); + expect(leftActions.scrollWidth).toBeLessThanOrEqual(leftActions.clientWidth + 1); + expect(footer.dataset.chatComposerFooterCompact).toBe("true"); + // The provider icon plus at least a few characters of model name. + expect(modelButton.getBoundingClientRect().width).toBeGreaterThan(48); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }, + ); - it("keeps model and reasoning labels while secondary controls are icon-only", async () => { + it("drops traits, mode, and access to icons together when the labels stop fitting, and restores them", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: fixture.snapshot, @@ -9267,66 +9300,54 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await waitForServerConfigToApply(); - await setVisibleComposerFormWidth(560); - const footer = await waitForElement( findVisibleComposerFooter, "Unable to find composer footer.", ); - await vi.waitFor(() => { - expect(footer.dataset.chatComposerFooterIconOnly).toBe("true"); - expect(footer.dataset.chatComposerFooterCompact).toBe("false"); - }); const modelButton = await waitForElement( () => footer.querySelector('[data-chat-provider-model-picker="true"]'), "Unable to find provider model picker.", ); - const reasoningButton = await waitForButtonByTextWithin(footer, "Medium"); - expect(modelButton.textContent).toContain("GPT-5"); - expect(reasoningButton.textContent).toBe("Medium"); - expect(modelButton.getBoundingClientRect().width).toBeGreaterThan(32); - expect(reasoningButton.getBoundingClientRect().width).toBeGreaterThan(32); - expect(footer.scrollWidth).toBeLessThanOrEqual(footer.clientWidth + 1); - } finally { - await mounted.cleanup(); - } - }); - - it("shrinks interaction and access controls to icons before using the overflow menu", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: fixture.snapshot, - }); + await waitForButtonByTextWithin(footer, "Medium"); + await waitForButtonByText("Full access"); - try { await setVisibleComposerFormWidth(560); - - const footer = await waitForElement( - findVisibleComposerFooter, - "Unable to find composer footer.", - ); await vi.waitFor(() => { expect(footer.dataset.chatComposerFooterIconOnly).toBe("true"); expect(footer.dataset.chatComposerFooterCompact).toBe("false"); }); - const interactionButton = await waitForElement( - () => footer.querySelector('button[aria-label^="Interaction mode:"]'), - "Unable to find icon-only interaction mode.", - ); - const accessButton = await waitForElement( - () => footer.querySelector('button[aria-label^="Access level:"]'), - "Unable to find icon-only access level.", + const iconButtons = await Promise.all( + [ + 'button[aria-label^="Reasoning settings:"]', + 'button[aria-label^="Interaction mode:"]', + 'button[aria-label^="Access level:"]', + ].map((selector) => + waitForElement( + () => footer.querySelector(selector), + `Unable to find icon-only control ${selector}.`, + ), + ), ); - const iconButtons = [interactionButton, accessButton]; const visibleSeparators = Array.from( footer.querySelectorAll('[data-slot="separator"]'), ).filter((separator) => getComputedStyle(separator).display !== "none"); expect(document.querySelector('button[aria-label="More composer controls"]')).toBeNull(); expect(visibleSeparators.length).toBeGreaterThanOrEqual(2); + expect(modelButton.textContent).toContain("GPT-5"); + expect(modelButton.getBoundingClientRect().width).toBeGreaterThan(32); for (const button of iconButtons) { expect(button.getBoundingClientRect().width).toBeLessThanOrEqual(32); } + expect(footer.scrollWidth).toBeLessThanOrEqual(footer.clientWidth + 1); + + // The way back: room returns, so do the labels. + await setVisibleComposerFormWidth(900); + await vi.waitFor(() => { + expect(footer.dataset.chatComposerFooterIconOnly).toBe("false"); + }); + await waitForButtonByTextWithin(footer, "Medium"); + await waitForButtonByText("Full access"); } finally { await mounted.cleanup(); } @@ -9494,7 +9515,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("uses icon-only footer controls when a wide desktop follow-up layout starts overflowing", async () => { + it("keeps the footer on one row when a wide desktop follow-up layout starts overflowing", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, snapshot: createSnapshotWithPlanFollowUpPrompt({ @@ -9523,9 +9544,14 @@ describe("ChatView timeline estimator parity (full app)", () => { const actions = document.querySelector( '[data-chat-composer-actions="right"]', ); - expect(footer?.dataset.chatComposerFooterIconOnly).toBe("true"); - expect(footer?.dataset.chatComposerFooterCompact).toBe("false"); expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("true"); + expect(footer).toBeTruthy(); + expect(footer!.scrollWidth).toBeLessThanOrEqual(footer!.clientWidth + 1); + const leftActions = footer!.querySelector( + '[data-chat-composer-actions="left"]', + ); + expect(leftActions).toBeTruthy(); + expect(leftActions!.scrollWidth).toBeLessThanOrEqual(leftActions!.clientWidth + 1); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5bd8be5e..abcf9fbb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -91,9 +91,13 @@ import type { TranscriptHighlightContextSelection, } from "../../lib/transcriptHighlightContext"; import { + type ComposerFooterTier, + INITIAL_COMPOSER_FOOTER_TIER_STATE, + isComposerFooterOverflowing, + measureComposerFooterOverflow, + resetComposerFooterTierWidths, + resolveComposerFooterTier, shouldUseCompactComposerPrimaryActions, - shouldUseCompactComposerFooter, - shouldUseIconOnlyComposerFooter, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, @@ -293,9 +297,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop <> @@ -323,10 +330,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop variant="ghost" size="sm" className={cn( - "font-medium", + "shrink-0 font-medium", props.iconOnly && "min-h-7 w-7 justify-center gap-0 px-0 [&_[data-slot=select-icon]]:hidden", - !props.iconOnly && "min-w-0 max-w-32 shrink", isPlanInteraction && "opacity-65", )} aria-label={`Access level: ${currentRuntimeModeOption.label}`} @@ -1142,8 +1148,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) null, ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); - const [isComposerFooterIconOnly, setIsComposerFooterIconOnly] = useState(false); + const [composerFooterTier, setComposerFooterTier] = useState("full"); + const isComposerFooterCompact = composerFooterTier === "compact"; + const isComposerFooterIconOnly = composerFooterTier === "icons"; const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isGoalEditorOpen, setIsGoalEditorOpen] = useState(false); @@ -1181,6 +1188,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); const composerFormHeightRef = useRef(0); + const composerFooterLeftActionsRef = useRef(null); + const composerFooterTierStateRef = useRef(INITIAL_COMPOSER_FOOTER_TIER_STATE); + const composerFooterContentKeyRef = useRef(null); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -1501,7 +1511,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedInstanceId], - iconOnly: isComposerFooterCompact, + iconOnly: composerFooterTier !== "full", }); const pendingPrimaryAction = useMemo( () => @@ -2167,50 +2177,70 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [draftId, activeThreadId, promptRef]); // ------------------------------------------------------------------ - // Footer compact layout observation + // Footer layout observation // ------------------------------------------------------------------ + // Left-hand footer content whose width the tier measurement depends on. + // When it changes, the recorded overflow widths describe labels that are + // gone, so the roomier tiers get retried at the current width. + const composerFooterContentKey = [ + selectedInstanceId, + String(selectedModelForPickerWithCustomFallback), + activeFallbackModelDisplayName ?? "", + interactionMode, + runtimeMode, + composerFooterActionLayoutKey, + composerFooterHasWideActions, + isComposerPrimaryActionsCompact, + JSON.stringify(composerModelOptions?.[selectedInstanceId] ?? null), + ].join("|"); + + // The left control group never shrinks, so when its content outgrows the + // row it overflows (the scrollbar is hidden) and the tier steps up: + // labels -> icons -> overflow menu. Runs in a layout effect so a tier + // change lands before paint, and from the ResizeObserver so live resizes + // re-evaluate without waiting for a render. + const evaluateComposerFooterTier = useCallback(() => { + const composerForm = composerFormRef.current; + const leftActions = composerFooterLeftActionsRef.current; + // A hidden footer measures as zero; keep the last tier until it shows. + if (!composerForm || !leftActions || leftActions.clientWidth === 0) return; + let state = composerFooterTierStateRef.current; + if (composerFooterContentKeyRef.current !== composerFooterContentKey) { + composerFooterContentKeyRef.current = composerFooterContentKey; + state = resetComposerFooterTierWidths(state); + } + const next = resolveComposerFooterTier(state, { + width: composerForm.clientWidth, + overflowing: isComposerFooterOverflowing(measureComposerFooterOverflow(leftActions)), + }); + composerFooterTierStateRef.current = next; + setComposerFooterTier(next.tier); + }, [composerFooterContentKey]); + + // Re-measure after every tier change: the narrower tier may still overflow, + // and a roomier one may fit again. + useLayoutEffect(() => { + if (composerFooterTierStateRef.current.tier !== composerFooterTier) return; + evaluateComposerFooterTier(); + }, [evaluateComposerFooterTier, composerFooterTier]); + useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - const measureFooterCompactness = () => { - const composerFormWidth = measureComposerFormWidth(); - const footerCompact = shouldUseCompactComposerFooter(composerFormWidth); - const footerIconOnly = shouldUseIconOnlyComposerFooter(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - const primaryActionsCompact = shouldUseCompactComposerPrimaryActions(composerFormWidth, { + const measurePrimaryActionsCompact = () => + shouldUseCompactComposerPrimaryActions(composerForm.clientWidth, { hasWideActions: composerFooterHasWideActions, }); - return { - primaryActionsCompact, - footerCompact, - footerIconOnly, - }; - }; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - const initialCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); - setIsComposerFooterCompact(initialCompactness.footerCompact); - setIsComposerFooterIconOnly(initialCompactness.footerIconOnly); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; - const nextCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact((previous) => - previous === nextCompactness.primaryActionsCompact - ? previous - : nextCompactness.primaryActionsCompact, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, - ); - setIsComposerFooterIconOnly((previous) => - previous === nextCompactness.footerIconOnly ? previous : nextCompactness.footerIconOnly, - ); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); + evaluateComposerFooterTier(); const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; @@ -2227,6 +2257,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions, + evaluateComposerFooterTier, scheduleStickToBottom, shouldAutoScrollRef, ]); @@ -3624,11 +3655,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-footer-icon-only={isComposerFooterIconOnly ? "true" : "false"} className={cn( "flex min-w-0 flex-nowrap items-center justify-between overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3", - isComposerFooterIconOnly ? "gap-x-1.5 gap-y-1.5" : "gap-x-2 gap-y-1.5 sm:gap-x-0", + // The left group's negative margin would touch the right group + // once the narrower tiers fill the row; keep a real gap there. + composerFooterTier === "full" + ? "gap-x-2 gap-y-1.5 sm:gap-x-0" + : "gap-x-1.5 gap-y-1.5", showMobilePendingAnswerActions && "hidden sm:flex", )} >
@@ -3691,7 +3727,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) runtimeModeOptions={composerProviderControls.runtimeModeOptions} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} - stashControlProps={stashControlProps} onInteractionModeChange={handleInteractionModeChange} onRuntimeModeChange={handleRuntimeModeChange} /> @@ -3716,13 +3751,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )}
- {/* Right side: add attachments + send / stop button */} + {/* Right side: stash + add attachments + send / stop button */}
- {!isComposerFooterCompact ? : null} + = ( icon: runtimeModeConfig[mode].icon, })); -const EMPTY_STASH_CONTROL_PROPS = { - entries: [], - open: false, - onOpenChange: () => undefined, - canStash: false, - stashShortcutLabel: null, - onStash: () => undefined, - onRestore: () => undefined, - onDelete: () => undefined, -} satisfies ComposerStashControlProps; - function selectDescriptor( id: string, label: string, @@ -150,7 +138,6 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str runtimeMode="approval-required" runtimeModeOptions={TEST_RUNTIME_MODE_OPTIONS} showInteractionModeToggle - stashControlProps={EMPTY_STASH_CONTROL_PROPS} traitsMenuContent={ { runtimeMode="approval-required" runtimeModeOptions={TEST_RUNTIME_MODE_OPTIONS} showInteractionModeToggle={false} - stashControlProps={EMPTY_STASH_CONTROL_PROPS} onInteractionModeChange={vi.fn()} onRuntimeModeChange={vi.fn()} />, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index dca94dda..a44b841d 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,6 +1,6 @@ import type { ProviderInteractionMode, RuntimeMode } from "@threadlines/contracts"; import { memo, type ReactNode, useState } from "react"; -import { BookmarkIcon, EllipsisIcon } from "lucide-react"; +import { EllipsisIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, @@ -8,153 +8,110 @@ import { MenuRadioGroup, MenuRadioItem, MenuSeparator as MenuDivider, - MenuSub, - MenuSubPopup, - MenuSubTrigger, MenuTrigger, } from "../ui/menu"; import { interactionModeOptions } from "../../interactionModeOptions"; import type { RuntimeModeOption } from "../../runtimeModeOptions"; -import { - ComposerStashDeleteConfirmation, - ComposerStashMenuItems, - type ComposerStashControlProps, - useComposerStashDelete, -} from "./ComposerStashControl"; +/** + * Narrow-footer overflow menu for the mode, access, and trait controls. The + * stash stays out of here on purpose: it keeps its own always-visible + * bookmark button, so a phone user never has to dig through a nested menu + * to park a prompt. + */ export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; - stashControlProps: ComposerStashControlProps; onInteractionModeChange: (mode: ProviderInteractionMode) => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const stashDelete = useComposerStashDelete(props.stashControlProps.onDelete); return ( - <> - { - if (!open && stashDelete.deleteConfirmOpen) return; - setIsMenuOpen(open); - if (!open && props.stashControlProps.open) { - props.stashControlProps.onOpenChange(false); - } - }} + + + } > - - } + + + {props.traitsMenuContent ? ( + <> + {props.traitsMenuContent} + + + ) : null} + {props.showInteractionModeToggle ? ( + <> +
Mode
+ { + if (!value || value === props.interactionMode) return; + props.onInteractionModeChange(value as ProviderInteractionMode); + }} + > + {interactionModeOptions.map((option) => { + const OptionIcon = option.icon; + return ( + + + + + ); + })} + + + + ) : null} +
Access
+ { + if (!value || value === props.runtimeMode) return; + props.onRuntimeModeChange(value as RuntimeMode); + }} > -
- - - - - - - - - - {props.traitsMenuContent ? ( - <> - {props.traitsMenuContent} - - - ) : null} - {props.showInteractionModeToggle ? ( - <> -
Mode
- { - if (!value || value === props.interactionMode) return; - props.onInteractionModeChange(value as ProviderInteractionMode); - }} + {props.runtimeModeOptions.map((option) => { + const OptionIcon = option.icon; + return ( + - {interactionModeOptions.map((option) => { - const OptionIcon = option.icon; - return ( - - - - - ); - })} - - - - ) : null} -
Access
- { - if (!value || value === props.runtimeMode) return; - props.onRuntimeModeChange(value as RuntimeMode); - }} - > - {props.runtimeModeOptions.map((option) => { - const OptionIcon = option.icon; - return ( - - - - - ); - })} - -
-
- - + + + + ); + })} + + +
); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 888ea2ed..1443ccb2 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -506,7 +506,7 @@ export const TraitsPicker = memo(function TraitsPicker({