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 {...@@ -214,6 +214,7 @@ import {
214 useComposerDraftStore,214 useComposerDraftStore,
215 useComposerThreadDraft,215 useComposerThreadDraft,
216} from "../composerDraftStore";216} from "../composerDraftStore";
217import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
217import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";218import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
218import { clamp } from "effect/Number";219import { clamp } from "effect/Number";
219import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";220import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
...@@ -639,6 +640,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -639,6 +640,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
639 useState<Record<string, number>>({});640 useState<Record<string, number>>({});
640 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});641 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
641 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);642 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
643 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
642 // Tracks whether the user explicitly dismissed the sidebar for the active turn.644 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
643 const planSidebarDismissedForTurnRef = useRef<string | null>(null);645 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
644 // When set, the thread-change reset effect will open the sidebar instead of closing it.646 // 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) {...@@ -935,6 +937,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
935 isComposerApprovalState ||937 isComposerApprovalState ||
936 pendingUserInputs.length > 0 ||938 pendingUserInputs.length > 0 ||
937 (showPlanFollowUpPrompt && activeProposedPlan !== null);939 (showPlanFollowUpPrompt && activeProposedPlan !== null);
940 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
938 useEffect(() => {941 useEffect(() => {
939 if (!activePendingProgress) {942 if (!activePendingProgress) {
940 return;943 return;
...@@ -1672,6 +1675,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1672,6 +1675,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
1672 const toggleInteractionMode = useCallback(() => {1675 const toggleInteractionMode = useCallback(() => {
1673 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");1676 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
1674 }, [handleInteractionModeChange, interactionMode]);1677 }, [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
1676 const persistThreadSettingsForNextTurn = useCallback(1692 const persistThreadSettingsForNextTurn = useCallback(
1677 async (input: {1693 async (input: {
...@@ -1890,12 +1906,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1890,12 +1906,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
1890 if (!composerForm) return;1906 if (!composerForm) return;
18911907
1892 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;1908 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
1909 setIsComposerFooterCompact(
1910 shouldUseCompactComposerFooter(composerForm.clientWidth, {
1911 hasWideActions: composerFooterHasWideActions,
1912 }),
1913 );
1893 if (typeof ResizeObserver === "undefined") return;1914 if (typeof ResizeObserver === "undefined") return;
18941915
1895 const observer = new ResizeObserver((entries) => {1916 const observer = new ResizeObserver((entries) => {
1896 const [entry] = entries;1917 const [entry] = entries;
1897 if (!entry) return;1918 if (!entry) return;
18981919
1920 const nextCompact = shouldUseCompactComposerFooter(entry.contentRect.width, {
1921 hasWideActions: composerFooterHasWideActions,
1922 });
1923 setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact));
1924
1899 const nextHeight = entry.contentRect.height;1925 const nextHeight = entry.contentRect.height;
1900 const previousHeight = composerFormHeightRef.current;1926 const previousHeight = composerFormHeightRef.current;
1901 composerFormHeightRef.current = nextHeight;1927 composerFormHeightRef.current = nextHeight;
...@@ -1909,7 +1935,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1909,7 +1935,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1909 return () => {1935 return () => {
1910 observer.disconnect();1936 observer.disconnect();
1911 };1937 };
1912 }, [activeThread?.id, scheduleStickToBottom]);1938 }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]);
1913 useEffect(() => {1939 useEffect(() => {
1914 if (!shouldAutoScrollRef.current) return;1940 if (!shouldAutoScrollRef.current) return;
1915 scheduleStickToBottom();1941 scheduleStickToBottom();
...@@ -3679,10 +3705,23 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3679,10 +3705,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
3679 />3705 />
3680 </div>3706 </div>
3681 ) : (3707 ) : (
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">3708 <div
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">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 >
3684 {/* Provider/model picker */}3722 {/* Provider/model picker */}
3685 <ProviderModelPicker3723 <ProviderModelPicker
3724 compact={isComposerFooterCompact}
3686 provider={selectedProvider}3725 provider={selectedProvider}
3687 model={selectedModelForPickerWithCustomFallback}3726 model={selectedModelForPickerWithCustomFallback}
3688 lockedProvider={lockedProvider}3727 lockedProvider={lockedProvider}
...@@ -3690,103 +3729,108 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3690,103 +3729,108 @@ export default function ChatView({ threadId }: ChatViewProps) {
3690 onProviderModelChange={onProviderModelSelect}3729 onProviderModelChange={onProviderModelSelect}
3691 />3730 />
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 ) : (
3694 <>3753 <>
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
3695 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3767 <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 */}3769 <Button
3707 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />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 <>
3757 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3787 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3788
3758 <Button3789 <Button
3759 variant="ghost"3790 variant="ghost"
3760 className={cn(3791 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
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 )}
3766 size="sm"3792 size="sm"
3767 type="button"3793 type="button"
3768 onClick={() => {3794 onClick={() =>
3769 setPlanSidebarOpen((open) => {3795 void handleRuntimeModeChange(
3770 if (open) {3796 runtimeMode === "full-access" ? "approval-required" : "full-access",
3771 // Closing: track dismissal for current turn3797 )
3772 const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null;3798 }
3773 if (turnKey) {3799 title={
3774 planSidebarDismissedForTurnRef.current = turnKey;3800 runtimeMode === "full-access"
3775 }3801 ? "Full access — click to require approvals"
3776 } else {3802 : "Approval required — click for full access"
3777 // Re-opening: clear dismissal tracking3803 }
3778 planSidebarDismissedForTurnRef.current = null;
3779 }
3780 return !open;
3781 });
3782 }}
3783 title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
3784 >3804 >
3785 <ListTodoIcon />3805 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3786 <span className="sr-only sm:not-sr-only">Plan</span>3806 <span className="sr-only sm:not-sr-only">
3807 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3808 </span>
3787 </Button>3809 </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}
3788 </>3832 </>
3789 ) : null}3833 )}
3790 </div>3834 </div>
37913835
3792 {/* Right side: send / stop button */}3836 {/* Right side: send / stop button */}
...@@ -5534,6 +5578,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5534,6 +5578,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5534 model: ModelSlug;5578 model: ModelSlug;
5535 lockedProvider: ProviderKind | null;5579 lockedProvider: ProviderKind | null;
5536 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;5580 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
5581 serviceTierSetting: AppServiceTier;
5582 compact?: boolean;
5537 disabled?: boolean;5583 disabled?: boolean;
5538 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;5584 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
5539}) {5585}) {
...@@ -5559,12 +5605,20 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5559,12 +5605,20 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5559 <Button5605 <Button
5560 size="sm"5606 size="sm"
5561 variant="ghost"5607 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 )}
5563 disabled={props.disabled}5612 disabled={props.disabled}
5564 />5613 />
5565 }5614 }
5566 >5615 >
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 >
5568 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />5622 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
5569 <span className="truncate">{selectedModelLabel}</span>5623 <span className="truncate">{selectedModelLabel}</span>
5570 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />5624 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
...@@ -5654,6 +5708,122 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5654,6 +5708,122 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5654 );5708 );
5655});5709});
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
5657const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {5827const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
5658 effort: CodexReasoningEffort;5828 effort: CodexReasoningEffort;
5659 fastModeEnabled: boolean;5829 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}