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.
This commit is contained in:
Brooklyn Nicholson 2026-07-15 03:17:50 -04:00
parent 092a97ef75
commit 305e26558e
3 changed files with 24 additions and 7 deletions

View file

@ -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
}

View file

@ -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<HTMLFormElement | null>
@ -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<boolean | null>(null)
const lastCompactPillRef = useRef<boolean | null>(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 }
}

View file

@ -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,