authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 10:45:16-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 11:57:27-07:00
logf62381090323ae15ffab480bdeeb868e61274fbf
tree10bbd9f7b3b36b15e27af2c7cf21b8bd1dd3cefd
parente9ffd5752bb56854f8693f4a37dee3b3b7afcd3e

Adapt composer footer to compact controls on narrow widths

- add shared footer layout breakpoint helper with tests - switch chat composer footer to compact menu when width is constrained - preserve plan sidebar toggle behavior while consolidating footer actions

3 files changed, 309 insertions(+), 90 deletions(-)

apps/web/src/components/ChatView.tsx+260-90
......@@ -214,6 +214,7 @@ import {
214214 useComposerDraftStore,
215215 useComposerThreadDraft,
216216} from "../composerDraftStore";
217import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
217218import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
218219import { clamp } from "effect/Number";
219220import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
......@@ -639,6 +640,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
639640 useState<Record<string, number>>({});
640641 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
641642 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
643 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
642644 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
643645 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
644646 // When set, the thread-change reset effect will open the sidebar instead of closing it.
......@@ -935,6 +937,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
935937 isComposerApprovalState ||
936938 pendingUserInputs.length > 0 ||
937939 (showPlanFollowUpPrompt && activeProposedPlan !== null);
940 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
938941 useEffect(() => {
939942 if (!activePendingProgress) {
940943 return;
......@@ -1672,6 +1675,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
16721675 const toggleInteractionMode = useCallback(() => {
16731676 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
16741677 }, [handleInteractionModeChange, interactionMode]);
1678 const togglePlanSidebar = useCallback(() => {
1679 setPlanSidebarOpen((open) => {
1680 if (open) {
1681 const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
1682 if (turnKey) {
1683 planSidebarDismissedForTurnRef.current = turnKey;
1684 }
1685 } else {
1686 planSidebarDismissedForTurnRef.current = null;
1687 }
1688 return !open;
1689 });
1690 }, [activePlan?.turnId, activeProposedPlan?.turnId]);
16751691
16761692 const persistThreadSettingsForNextTurn = useCallback(
16771693 async (input: {
......@@ -1890,12 +1906,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
18901906 if (!composerForm) return;
18911907
18921908 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1909 setIsComposerFooterCompact(
1910 shouldUseCompactComposerFooter(composerForm.clientWidth, {
1911 hasWideActions: composerFooterHasWideActions,
1912 }),
1913 );
18931914 if (typeof ResizeObserver === "undefined") return;
18941915
18951916 const observer = new ResizeObserver((entries) => {
18961917 const [entry] = entries;
18971918 if (!entry) return;
18981919
1920 const nextCompact = shouldUseCompactComposerFooter(entry.contentRect.width, {
1921 hasWideActions: composerFooterHasWideActions,
1922 });
1923 setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact));
1924
18991925 const nextHeight = entry.contentRect.height;
19001926 const previousHeight = composerFormHeightRef.current;
19011927 composerFormHeightRef.current = nextHeight;
......@@ -1909,7 +1935,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19091935 return () => {
19101936 observer.disconnect();
19111937 };
1912 }, [activeThread?.id, scheduleStickToBottom]);
1938 }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]);
19131939 useEffect(() => {
19141940 if (!shouldAutoScrollRef.current) return;
19151941 scheduleStickToBottom();
......@@ -3679,10 +3705,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
36793705 />
36803706 </div>
36813707 ) : (
3682 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">
3683 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">
3708 <div
3709 className={cn(
3710 "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3",
3711 isComposerFooterCompact ? "gap-1.5" : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0",
3712 )}
3713 >
3714 <div
3715 className={cn(
3716 "flex min-w-0 flex-1 items-center",
3717 isComposerFooterCompact
3718 ? "gap-1 overflow-hidden"
3719 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
3720 )}
3721 >
36843722 {/* Provider/model picker */}
36853723 <ProviderModelPicker
3724 compact={isComposerFooterCompact}
36863725 provider={selectedProvider}
36873726 model={selectedModelForPickerWithCustomFallback}
36883727 lockedProvider={lockedProvider}
......@@ -3690,103 +3729,108 @@ export default function ChatView({ threadId }: ChatViewProps) {
36903729 onProviderModelChange={onProviderModelSelect}
36913730 />
36923731
3693 {selectedProvider === "codex" && selectedEffort != null ? (
3732 {isComposerFooterCompact ? (
3733 <CompactComposerControlsMenu
3734 activePlan={Boolean(activePlan || activeProposedPlan || planSidebarOpen)}
3735 interactionMode={interactionMode}
3736 planSidebarOpen={planSidebarOpen}
3737 runtimeMode={runtimeMode}
3738 selectedEffort={selectedEffort}
3739 selectedProvider={selectedProvider}
3740 selectedCodexFastModeEnabled={selectedCodexFastModeEnabled}
3741 reasoningOptions={reasoningOptions}
3742 onEffortSelect={onEffortSelect}
3743 onCodexFastModeChange={onCodexFastModeChange}
3744 onToggleInteractionMode={toggleInteractionMode}
3745 onTogglePlanSidebar={togglePlanSidebar}
3746 onToggleRuntimeMode={() =>
3747 void handleRuntimeModeChange(
3748 runtimeMode === "full-access" ? "approval-required" : "full-access",
3749 )
3750 }
3751 />
3752 ) : (
36943753 <>
3754 {selectedProvider === "codex" && selectedEffort != null ? (
3755 <>
3756 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3757 <CodexTraitsPicker
3758 effort={selectedEffort}
3759 fastModeEnabled={selectedCodexFastModeEnabled}
3760 options={reasoningOptions}
3761 onEffortChange={onEffortSelect}
3762 onFastModeChange={onCodexFastModeChange}
3763 />
3764 </>
3765 ) : null}
3766
36953767 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3696 <CodexTraitsPicker
3697 effort={selectedEffort}
3698 fastModeEnabled={selectedCodexFastModeEnabled}
3699 options={reasoningOptions}
3700 onEffortChange={onEffortSelect}
3701 onFastModeChange={onCodexFastModeChange}
3702 />
3703 </>
3704 ) : null}
37053768
3706 {/* Divider */}
3707 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3769 <Button
3770 variant="ghost"
3771 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3772 size="sm"
3773 type="button"
3774 onClick={toggleInteractionMode}
3775 title={
3776 interactionMode === "plan"
3777 ? "Plan mode — click to return to normal chat mode"
3778 : "Default mode — click to enter plan mode"
3779 }
3780 >
3781 <BotIcon />
3782 <span className="sr-only sm:not-sr-only">
3783 {interactionMode === "plan" ? "Plan" : "Chat"}
3784 </span>
3785 </Button>
37083786
3709 {/* Interaction mode toggle */}
3710 <Button
3711 variant="ghost"
3712 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3713 size="sm"
3714 type="button"
3715 onClick={toggleInteractionMode}
3716 title={
3717 interactionMode === "plan"
3718 ? "Plan mode — click to return to normal chat mode"
3719 : "Default mode — click to enter plan mode"
3720 }
3721 >
3722 <BotIcon />
3723 <span className="sr-only sm:not-sr-only">
3724 {interactionMode === "plan" ? "Plan" : "Chat"}
3725 </span>
3726 </Button>
3727
3728 {/* Divider */}
3729 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3730
3731 {/* Runtime mode toggle */}
3732 <Button
3733 variant="ghost"
3734 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3735 size="sm"
3736 type="button"
3737 onClick={() =>
3738 void handleRuntimeModeChange(
3739 runtimeMode === "full-access" ? "approval-required" : "full-access",
3740 )
3741 }
3742 title={
3743 runtimeMode === "full-access"
3744 ? "Full access — click to require approvals"
3745 : "Approval required — click for full access"
3746 }
3747 >
3748 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3749 <span className="sr-only sm:not-sr-only">
3750 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3751 </span>
3752 </Button>
3753
3754 {/* Plan sidebar toggle */}
3755 {(activePlan || activeProposedPlan || planSidebarOpen) ? (
3756 <>
37573787 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3788
37583789 <Button
37593790 variant="ghost"
3760 className={cn(
3761 "shrink-0 whitespace-nowrap px-2 sm:px-3",
3762 planSidebarOpen
3763 ? "text-blue-400 hover:text-blue-300"
3764 : "text-muted-foreground/70 hover:text-foreground/80",
3765 )}
3791 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
37663792 size="sm"
37673793 type="button"
3768 onClick={() => {
3769 setPlanSidebarOpen((open) => {
3770 if (open) {
3771 // Closing: track dismissal for current turn
3772 const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;
3773 if (turnKey) {
3774 planSidebarDismissedForTurnRef.current = turnKey;
3775 }
3776 } else {
3777 // Re-opening: clear dismissal tracking
3778 planSidebarDismissedForTurnRef.current = null;
3779 }
3780 return !open;
3781 });
3782 }}
3783 title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
3794 onClick={() =>
3795 void handleRuntimeModeChange(
3796 runtimeMode === "full-access" ? "approval-required" : "full-access",
3797 )
3798 }
3799 title={
3800 runtimeMode === "full-access"
3801 ? "Full access — click to require approvals"
3802 : "Approval required — click for full access"
3803 }
37843804 >
3785 <ListTodoIcon />
3786 <span className="sr-only sm:not-sr-only">Plan</span>
3805 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3806 <span className="sr-only sm:not-sr-only">
3807 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3808 </span>
37873809 </Button>
3810
3811 {(activePlan || activeProposedPlan || planSidebarOpen) ? (
3812 <>
3813 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3814 <Button
3815 variant="ghost"
3816 className={cn(
3817 "shrink-0 whitespace-nowrap px-2 sm:px-3",
3818 planSidebarOpen
3819 ? "text-blue-400 hover:text-blue-300"
3820 : "text-muted-foreground/70 hover:text-foreground/80",
3821 )}
3822 size="sm"
3823 type="button"
3824 onClick={togglePlanSidebar}
3825 title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
3826 >
3827 <ListTodoIcon />
3828 <span className="sr-only sm:not-sr-only">Plan</span>
3829 </Button>
3830 </>
3831 ) : null}
37883832 </>
3789 ) : null}
3833 )}
37903834 </div>
37913835
37923836 {/* Right side: send / stop button */}
......@@ -5534,6 +5578,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
55345578 model: ModelSlug;
55355579 lockedProvider: ProviderKind | null;
55365580 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
5581 serviceTierSetting: AppServiceTier;
5582 compact?: boolean;
55375583 disabled?: boolean;
55385584 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
55395585}) {
......@@ -5559,12 +5605,20 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
55595605 <Button
55605606 size="sm"
55615607 variant="ghost"
5562 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
5608 className={cn(
5609 "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80",
5610 props.compact ? "max-w-[10.5rem]" : "sm:px-3",
5611 )}
55635612 disabled={props.disabled}
55645613 />
55655614 }
55665615 >
5567 <span className="flex min-w-0 items-center gap-2">
5616 <span
5617 className={cn(
5618 "flex min-w-0 items-center gap-2",
5619 props.compact ? "max-w-[9rem]" : undefined,
5620 )}
5621 >
55685622 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
55695623 <span className="truncate">{selectedModelLabel}</span>
55705624 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
......@@ -5654,6 +5708,122 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
56545708 );
56555709});
56565710
5711const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
5712 activePlan: boolean;
5713 interactionMode: ProviderInteractionMode;
5714 planSidebarOpen: boolean;
5715 runtimeMode: RuntimeMode;
5716 selectedEffort: CodexReasoningEffort | null;
5717 selectedProvider: ProviderKind;
5718 selectedCodexFastModeEnabled: boolean;
5719 reasoningOptions: ReadonlyArray<CodexReasoningEffort>;
5720 onEffortSelect: (effort: CodexReasoningEffort) => void;
5721 onCodexFastModeChange: (enabled: boolean) => void;
5722 onToggleInteractionMode: () => void;
5723 onTogglePlanSidebar: () => void;
5724 onToggleRuntimeMode: () => void;
5725}) {
5726 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
5727 const reasoningLabelByOption: Record<CodexReasoningEffort, string> = {
5728 low: "Low",
5729 medium: "Medium",
5730 high: "High",
5731 xhigh: "Extra High",
5732 };
5733
5734 return (
5735 <Menu>
5736 <MenuTrigger
5737 render={
5738 <Button
5739 size="sm"
5740 variant="ghost"
5741 className="shrink-0 px-2 text-muted-foreground/70 hover:text-foreground/80"
5742 aria-label="More composer controls"
5743 />
5744 }
5745 >
5746 <EllipsisIcon aria-hidden="true" className="size-4" />
5747 </MenuTrigger>
5748 <MenuPopup align="start">
5749 {props.selectedProvider === "codex" && props.selectedEffort != null ? (
5750 <>
5751 <MenuGroup>
5752 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div>
5753 <MenuRadioGroup
5754 value={props.selectedEffort}
5755 onValueChange={(value) => {
5756 if (!value) return;
5757 const nextEffort = props.reasoningOptions.find((option) => option === value);
5758 if (!nextEffort) return;
5759 props.onEffortSelect(nextEffort);
5760 }}
5761 >
5762 {props.reasoningOptions.map((effort) => (
5763 <MenuRadioItem key={effort} value={effort}>
5764 {reasoningLabelByOption[effort]}
5765 {effort === defaultReasoningEffort ? " (default)" : ""}
5766 </MenuRadioItem>
5767 ))}
5768 </MenuRadioGroup>
5769 </MenuGroup>
5770 <MenuDivider />
5771 <MenuGroup>
5772 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
5773 <MenuRadioGroup
5774 value={props.selectedCodexFastModeEnabled ? "on" : "off"}
5775 onValueChange={(value) => {
5776 props.onCodexFastModeChange(value === "on");
5777 }}
5778 >
5779 <MenuRadioItem value="off">off</MenuRadioItem>
5780 <MenuRadioItem value="on">on</MenuRadioItem>
5781 </MenuRadioGroup>
5782 </MenuGroup>
5783 <MenuDivider />
5784 </>
5785 ) : null}
5786 <MenuGroup>
5787 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>
5788 <MenuRadioGroup
5789 value={props.interactionMode}
5790 onValueChange={(value) => {
5791 if (!value || value === props.interactionMode) return;
5792 props.onToggleInteractionMode();
5793 }}
5794 >
5795 <MenuRadioItem value="default">Chat</MenuRadioItem>
5796 <MenuRadioItem value="plan">Plan</MenuRadioItem>
5797 </MenuRadioGroup>
5798 </MenuGroup>
5799 <MenuDivider />
5800 <MenuGroup>
5801 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div>
5802 <MenuRadioGroup
5803 value={props.runtimeMode}
5804 onValueChange={(value) => {
5805 if (!value || value === props.runtimeMode) return;
5806 props.onToggleRuntimeMode();
5807 }}
5808 >
5809 <MenuRadioItem value="approval-required">Supervised</MenuRadioItem>
5810 <MenuRadioItem value="full-access">Full access</MenuRadioItem>
5811 </MenuRadioGroup>
5812 </MenuGroup>
5813 {props.activePlan ? (
5814 <>
5815 <MenuDivider />
5816 <MenuItem onClick={props.onTogglePlanSidebar}>
5817 <ListTodoIcon className="size-4 shrink-0" />
5818 {props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
5819 </MenuItem>
5820 </>
5821 ) : null}
5822 </MenuPopup>
5823 </Menu>
5824 );
5825});
5826
56575827const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
56585828 effort: CodexReasoningEffort;
56595829 fastModeEnabled: boolean;
apps/web/src/components/composerFooterLayout.test.ts created+37
......@@ -0,0 +1,37 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
5 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
6 shouldUseCompactComposerFooter,
7} from "./composerFooterLayout";
8
9describe("shouldUseCompactComposerFooter", () => {
10 it("stays expanded without a measured width", () => {
11 expect(shouldUseCompactComposerFooter(null)).toBe(false);
12 });
13
14 it("switches to compact mode below the breakpoint", () => {
15 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX - 1)).toBe(true);
16 });
17
18 it("stays expanded at and above the breakpoint", () => {
19 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX)).toBe(false);
20 expect(shouldUseCompactComposerFooter(COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX + 48)).toBe(
21 false,
22 );
23 });
24
25 it("uses a higher breakpoint for wide action states", () => {
26 expect(
27 shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX - 1, {
28 hasWideActions: true,
29 }),
30 ).toBe(true);
31 expect(
32 shouldUseCompactComposerFooter(COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, {
33 hasWideActions: true,
34 }),
35 ).toBe(false);
36 });
37});
apps/web/src/components/composerFooterLayout.ts created+12
......@@ -0,0 +1,12 @@
1export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
2export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 720;
3
4export function shouldUseCompactComposerFooter(
5 width: number | null,
6 options?: { hasWideActions?: boolean },
7): boolean {
8 const breakpoint = options?.hasWideActions
9 ? COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX
10 : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX;
11 return width !== null && width < breakpoint;
12}