From ce8ad8c68f287df4a4ad0bf5af928b63495521c8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 1 Jun 2026 22:58:03 +0800 Subject: [PATCH] Let composer controls adapt to narrow panes (#1270) --- .../app/src/composer/agent-controls/index.tsx | 37 ++++++++++++--- .../composer/agent-controls/mode-control.tsx | 10 ++++- .../app/src/composer/draft/workspace-tab.tsx | 30 +++++++++---- packages/app/src/composer/index.tsx | 45 +++++++++++++------ packages/app/src/constants/layout.ts | 1 + packages/app/src/hooks/use-container-width.ts | 7 ++- packages/app/src/panels/agent-panel.tsx | 27 +++++++---- 7 files changed, 118 insertions(+), 39 deletions(-) diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 39b2375d3..f8c81522f 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -97,6 +97,7 @@ interface ControlledAgentControlsProps { /** Extra elements rendered inline with the agent controls (desktop only). */ desktopExtras?: ReactNode; modelSelectorServerId?: string | null; + isCompactLayout?: boolean; } export interface DraftAgentControlsProps { @@ -124,12 +125,14 @@ export interface DraftAgentControlsProps { isRetryingModelProvider?: boolean; disabled?: boolean; modelSelectorServerId?: string | null; + isCompactLayout?: boolean; } interface AgentControlsProps { agentId: string; serverId: string; onDropdownClose?: () => void; + isCompactLayout?: boolean; } function findOptionLabel( @@ -409,9 +412,11 @@ function ControlledAgentControls({ isRetryingModelProvider = false, desktopExtras, modelSelectorServerId = null, + isCompactLayout, }: ControlledAgentControlsProps) { const { theme } = useUnistyles(); - const isCompact = useIsCompactFormFactor(); + const isCompactFormFactor = useIsCompactFormFactor(); + const isCompact = isCompactLayout ?? isCompactFormFactor; const [activeSheet, setActiveSheet] = useState(null); const [openSelector, setOpenSelector] = useState(null); @@ -1345,6 +1350,7 @@ export const AgentControls = memo(function AgentControls({ agentId, serverId, onDropdownClose, + isCompactLayout, }: AgentControlsProps) { const { preferences, updatePreferences } = useFormPreferences(); const agent = useSessionStore( @@ -1518,8 +1524,15 @@ export const AgentControls = memo(function AgentControls({ ); const modeChip = useMemo( - () => , - [serverId, agentId], + () => ( + + ), + [serverId, agentId, isCompactLayout], ); if (!agent) { @@ -1548,6 +1561,7 @@ export const AgentControls = memo(function AgentControls({ disabled={!client} desktopExtras={modeChip} modelSelectorServerId={serverId} + isCompactLayout={isCompactLayout} /> ); }); @@ -1577,9 +1591,11 @@ export function DraftAgentControls({ isRetryingModelProvider = false, disabled = false, modelSelectorServerId = null, + isCompactLayout, }: DraftAgentControlsProps) { const { preferences, updatePreferences } = useFormPreferences(); - const isCompact = useIsCompactFormFactor(); + const isCompactFormFactor = useIsCompactFormFactor(); + const isCompact = isCompactLayout ?? isCompactFormFactor; const mappedThinkingOptions = useMemo(() => { return toThinkingControlOptions(thinkingOptions); @@ -1625,9 +1641,18 @@ export function DraftAgentControls({ selectedMode={selectedMode} onSelectMode={onSelectMode} disabled={disabled} + isCompactLayout={isCompactLayout} /> ), - [selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled], + [ + selectedProvider, + providerDefinitions, + modeOptions, + selectedMode, + onSelectMode, + disabled, + isCompactLayout, + ], ); if (!isCompact) { @@ -1661,6 +1686,7 @@ export function DraftAgentControls({ isRetryingModelProvider={isRetryingModelProvider} disabled={disabled} desktopExtras={draftModeChip} + isCompactLayout={isCompactLayout} /> ) : null} @@ -1688,6 +1714,7 @@ export function DraftAgentControls({ isRetryingModelProvider={isRetryingModelProvider} disabled={disabled} modelSelectorServerId={modelSelectorServerId} + isCompactLayout={isCompactLayout} /> ); } diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 8d92992ef..2032e8715 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -235,14 +235,17 @@ interface AgentModeControlProps { serverId: string; agentId: string; placement: AgentModeControlPlacement; + isCompactLayout?: boolean; } export const AgentModeControl = memo(function AgentModeControl({ serverId, agentId, placement, + isCompactLayout, }: AgentModeControlProps) { - const isCompact = useIsCompactFormFactor(); + const isCompactFormFactor = useIsCompactFormFactor(); + const isCompact = isCompactLayout ?? isCompactFormFactor; const slice = useSessionStore( useShallow((state) => { const agent = state.sessions[serverId]?.agents?.get(agentId); @@ -303,6 +306,7 @@ export interface DraftAgentModeControlProps { onSelectMode: (modeId: string) => void; disabled?: boolean; placement: AgentModeControlPlacement; + isCompactLayout?: boolean; } export function DraftAgentModeControl({ @@ -313,8 +317,10 @@ export function DraftAgentModeControl({ onSelectMode, disabled, placement, + isCompactLayout, }: DraftAgentModeControlProps) { - const isCompact = useIsCompactFormFactor(); + const isCompactFormFactor = useIsCompactFormFactor(); + const isCompact = isCompactLayout ?? isCompactFormFactor; if (!selectedProvider || modeOptions.length === 0) return null; if (!shouldRenderForPlacement(placement, isCompact)) return null; return ( diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 3c4ab0b9d..8c09efab0 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -4,6 +4,7 @@ import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; @@ -36,7 +37,11 @@ import { useWorkspaceAttachmentScopeKey, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; -import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { + COMPACT_FORM_FACTOR_WIDTH, + MAX_CONTENT_WIDTH, + useIsCompactFormFactor, +} from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; @@ -380,7 +385,11 @@ export function WorkspaceDraftAgentTab({ }; }, [pendingAutoSubmit, pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; - const isCompact = useIsCompactFormFactor(); + const isCompactFormFactor = useIsCompactFormFactor(); + const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( + COMPACT_FORM_FACTOR_WIDTH, + { initialIsBelow: isCompactFormFactor }, + ); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd: composerState.workingDir, @@ -401,14 +410,14 @@ export function WorkspaceDraftAgentTab({ }; openFileExplorerForCheckout({ checkout, - isCompact, + isCompact: isCompactFormFactor, }); setExplorerTabForCheckout({ ...checkout, tab: "changes", }); }, - [isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout], + [isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout], ); const { @@ -625,10 +634,14 @@ export function WorkspaceDraftAgentTab({ ); const composerFooter = useMemo( () => - isCompact ? ( - + isCompactComposerLayout ? ( + ) : undefined, - [isCompact, composerAgentControls], + [isCompactComposerLayout, composerAgentControls], ); return ( @@ -662,7 +675,7 @@ export function WorkspaceDraftAgentTab({ )} - + {importPillPress ? ( @@ -692,6 +705,7 @@ export function WorkspaceDraftAgentTab({ commandDraftConfig={composerState.commandDraftConfig} agentControls={composerAgentControls} footer={composerFooter} + isCompactLayout={isCompactComposerLayout} /> diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 954d90f24..15a20ba6a 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -140,6 +140,10 @@ function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean { return isWeb && !isMobile; } +function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean { + return override ?? formFactor; +} + function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string { return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; } @@ -223,14 +227,22 @@ interface RenderLeftContentArgs { agentId: string; serverId: string; focusInput: () => void; + isCompactLayout: boolean; } function renderLeftContent(args: RenderLeftContentArgs): ReactElement { - const { agentControls, agentId, serverId, focusInput } = args; + const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args; if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) { - return ; + return ; } - return ; + return ( + + ); } interface RenderAttachmentTrayArgs { @@ -674,6 +686,8 @@ interface ComposerProps { footer?: ReactNode; /** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */ externalKeyboardShift?: boolean; + /** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */ + isCompactLayout?: boolean; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; @@ -869,6 +883,7 @@ export function Composer({ inputWrapperStyle, footer, externalKeyboardShift, + isCompactLayout: isCompactLayoutOverride, }: ComposerProps) { const buttonIconSize = resolveComposerButtonIconSize(); const client = useHostRuntimeClient(serverId); @@ -899,9 +914,11 @@ export function Composer({ const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const isMobile = useIsCompactFormFactor(); - const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isMobile); - const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint); + const isCompactFormFactor = useIsCompactFormFactor(); + const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); + const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); + const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); + const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout); const userInput = value; const setUserInput = onChangeText; const { @@ -1437,7 +1454,7 @@ export function Composer({ isAgentRunning={isAgentRunning} hasSendableContent={hasSendableContent} isProcessing={isProcessing} - isCompact={isMobile} + isCompact={isCompactLayout} buttonIconSize={buttonIconSize} handleToggleRealtimeVoice={handleToggleRealtimeVoice} isConnected={isConnected} @@ -1455,7 +1472,7 @@ export function Composer({ hasSendableContent, isAgentRunning, isConnected, - isMobile, + isCompactLayout, isProcessing, isVoiceModeForAgent, isVoiceSwitching, @@ -1475,13 +1492,13 @@ export function Composer({ contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, - isMobile, + isCompactLayout, ), - [contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isMobile], + [contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout], ); const { beforeVoiceContent, footerInlineContent } = useMemo( - () => resolveContextWindowPlacement(contextWindowMeter, isMobile), - [contextWindowMeter, isMobile], + () => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout), + [contextWindowMeter, isCompactLayout], ); const githubSearchQueryTrimmed = githubSearchQuery.trim(); @@ -1548,8 +1565,8 @@ export function Composer({ ); const leftContent = useMemo( - () => renderLeftContent({ agentControls, agentId, serverId, focusInput }), - [agentId, focusInput, serverId, agentControls], + () => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }), + [agentId, focusInput, serverId, agentControls, isCompactLayout], ); const handleAttachButtonRef = useCallback((node: View | null) => { diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index a8b077862..578feb84d 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -13,6 +13,7 @@ export const HEADER_TOP_PADDING_MOBILE = 8; // Max width for chat content (stream view, input area, new agent form) export const MAX_CONTENT_WIDTH = 820; +export const COMPACT_FORM_FACTOR_WIDTH = 768; // Desktop app constants for macOS traffic light buttons // These buttons (close/minimize/maximize) overlay the top-left corner diff --git a/packages/app/src/hooks/use-container-width.ts b/packages/app/src/hooks/use-container-width.ts index 86654c3f2..f04c22188 100644 --- a/packages/app/src/hooks/use-container-width.ts +++ b/packages/app/src/hooks/use-container-width.ts @@ -20,11 +20,14 @@ export function useContainerWidth(): { /** * Tracks only whether a container is narrower than a threshold. */ -export function useContainerWidthBelow(threshold: number): { +export function useContainerWidthBelow( + threshold: number, + options?: { initialIsBelow?: boolean }, +): { onLayout: (e: LayoutChangeEvent) => void; isBelow: boolean; } { - const [isBelow, setIsBelow] = useState(true); + const [isBelow, setIsBelow] = useState(options?.initialIsBelow ?? true); return { onLayout: useCallback( (e: LayoutChangeEvent) => { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1b73bb1c8..57f20a63e 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -22,7 +22,7 @@ import { useWorkspaceAttachments, useWorkspaceAttachmentScopeKey, } from "@/attachments/workspace-attachments-store"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; @@ -36,6 +36,7 @@ import { } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; @@ -1317,7 +1318,11 @@ function ActiveAgentComposer({ onMessageSent: () => void; }) { const insets = useSafeAreaInsets(); - const isCompact = useIsCompactFormFactor(); + const isCompactFormFactor = useIsCompactFormFactor(); + const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( + COMPACT_FORM_FACTOR_WIDTH, + { initialIsBelow: isCompactFormFactor }, + ); const paneContext = usePaneContext(); const { workspaceId, tabId, retargetCurrentTab } = paneContext; const { archiveAgent } = useArchiveAgent(); @@ -1355,14 +1360,14 @@ function ActiveAgentComposer({ }; openFileExplorerForCheckout({ checkout, - isCompact, + isCompact: isCompactFormFactor, }); setExplorerTabForCheckout({ ...checkout, tab: "changes", }); }, - [isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout], + [isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout], ); const handleClientSlashCommand = useCallback( @@ -1414,14 +1419,19 @@ function ActiveAgentComposer({ const composerFooter = useMemo( () => - isCompact ? ( - + isCompactComposerLayout ? ( + ) : undefined, - [isCompact, serverId, agentId], + [isCompactComposerLayout, serverId, agentId], ); return ( - + );