From 305e26558ea6811be010a5e37ab867a8bb5125f9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Jul 2026 03:17:50 -0400 Subject: [PATCH] fix(desktop): composer progressively collapses on narrow tiles Drive the composer's layout off its OWN measured width (the existing ResizeObserver, so it reacts per-tile, not per-viewport) instead of only stacking or overflowing: - >=440px: full inline row, full model label. - 320-440px: still inline, but the model pill sheds its label for its chevron icon (~120px back) so the controls stop crowding the placeholder. - <320px: stacks to the two-row layout, pill stays iconized. Adds COMPOSER_COMPACT_PILL_PX above the stack breakpoint and a `compactPill` signal from useComposerMetrics, wired to the model pill's existing compact mode. --- .../src/app/chat/composer/composer-utils.ts | 10 +++++++--- .../chat/composer/hooks/use-composer-metrics.ts | 17 +++++++++++++++-- apps/desktop/src/app/chat/composer/index.tsx | 4 ++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 66f438f62708..7939be35b6b6 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -6,6 +6,12 @@ import { setSessionPickerOpen } from '@/store/session' export const COMPOSER_STACK_BREAKPOINT_PX = 320 +// Above the stack breakpoint but still cramped: the model pill sheds its label +// for its chevron icon (freeing ~120px) so the controls stop crowding the input +// before the whole row has to stack. Progressive collapse: full pill → icon +// pill → stacked. +export const COMPOSER_COMPACT_PILL_PX = 440 + // A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem // vertical padding). Anything taller means the text wrapped to a second line, // which is when the composer should expand to the stacked layout. @@ -79,7 +85,5 @@ export function isPendingDraftPersistCurrent( pending: PendingDraftPersist | null, expected: PendingDraftPersist | null ): boolean { - return ( - pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text - ) + return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index da66ddd843aa..318802bbab80 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -6,7 +6,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer' import { $composerPoppedOut } from '@/store/composer-popout' import { isSecondaryWindow } from '@/store/windows' -import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' +import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' interface UseComposerMetricsArgs { composerRef: RefObject @@ -24,10 +24,13 @@ interface UseComposerMetricsArgs { * Returns `stacked` (the only value the render needs). */ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): { + compactPill: boolean stacked: boolean } { const [expanded, setExpanded] = useState(false) const [tight, setTight] = useState(false) + // Wider than `tight`: the pill goes icon-only before the row has to stack. + const [compactPill, setCompactPill] = useState(false) const narrow = useMediaQuery('(max-width: 30rem)') // Edge signals, not the live text: these only re-render when emptiness / the @@ -72,6 +75,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, const lastBucketedHeightRef = useRef(0) const lastBucketedSurfaceHeightRef = useRef(0) const lastTightRef = useRef(null) + const lastCompactPillRef = useRef(null) const syncComposerMetrics = useCallback(() => { const composer = composerRef.current @@ -105,6 +109,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, lastTightRef.current = nextTight setTight(nextTight) } + + const nextCompactPill = width < COMPOSER_COMPACT_PILL_PX + + if (nextCompactPill !== lastCompactPillRef.current) { + lastCompactPillRef.current = nextCompactPill + setCompactPill(nextCompactPill) + } } // Expand once the input has actually wrapped past a single line. The @@ -156,5 +167,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, } }, []) - return { stacked: expanded || narrow || tight } + // Pill compacts on real width (tile/pane), OR when stacked for any reason + // (viewport-narrow / wrapped) so the controls row never over-runs. + return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight } } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 661c4dbbae4a..7e06165fc711 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -208,7 +208,7 @@ export function ChatBar({ const statusStackVisible = queuedPrompts.length > 0 || statusPresent - const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }) + const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }) const hasComposerPayload = hasText || attachments.length > 0 const canSubmit = busy || hasComposerPayload const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' @@ -704,7 +704,7 @@ export function ChatBar({ busyAction={busyAction} canSteer={canSteer} canSubmit={canSubmit} - compactModelPill={poppedOut} + compactModelPill={poppedOut || compactPill} conversation={{ active: voiceConversationActive, level: conversation.level,