From 46042f94651ec8b259e164a53cf4bb48cb4c4802 Mon Sep 17 00:00:00 2001 From: gagan Date: Mon, 8 Jun 2026 02:10:23 +0530 Subject: [PATCH] fix: keep chat input toolbar usable when the panel is narrow (#606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent chat bar model selector from overflowing in narrow panel * fix: contain chat bar left items so code pill clips instead of overflowing * fix: compact icon-only mode for chat bar when panel is narrow * fix: dynamic compact threshold based on visible toolbar items * fix: use actual DOM overflow detection to eliminate toolbar overlap * fix: progressive right-to-left icon collapse for chat toolbar * fix: instant icon switch, remove search label transition * fix: correct right-to-left collapse order (code→perm→search→workDir) * fix: measure actual DOM overflow instead of estimating — eliminates half-text and disappearing icons * refactor: replace JS overflow logic with CSS container queries Drop the ResizeObserver/useLayoutEffect collapse machinery and the estimated pixel thresholds in favor of declarative @container variants. Each toolbar item swaps to icon-only at a fixed container-width breakpoint (code 560, perm 460, search 410, workDir 370px), collapsing right-to-left. Atomic swaps mean no half-clipped text and no disappearing buttons. * fix: move @container to card root so breakpoints track panel width Putting container-type on the toolbar's own flex row made it stop stretching to fill the card and hug its collapsed content instead, so the query read a permanently-narrow width that never grew on widen. The card root reliably spans the full panel width. * fix: collapse toolbar by measuring real overflow, not fixed breakpoints Fixed container-query breakpoints can't know the workdir name length or model name width, so labels stayed full and overflowed into the model selector. Replace with overflow measurement: a ResizeObserver resets to full on any width/content change, then a pre-paint layout effect collapses items right-to-left (code -> perm -> search -> workdir) until the row fits. overflow-hidden on the group is a hard guarantee against any overlap. * feat: overflow menu for toolbar items that don't fit even as icons When the bar is too narrow to show every control as an icon, the right-most items move into a '...' overflow dropdown (code -> perm -> search -> workdir) instead of being clipped, so no icon is ever hidden. Toggle items keep the menu open on click via onSelect preventDefault. * fix: keep overflow menu open when toggling items inside it Toggling an in-menu item (code mode, agent, search, perm) updated state that was in the collapse-reset deps, resetting collapseLevel to 0 and unmounting the '...' trigger mid-interaction. Drop the in-place toggles from the reset deps so the menu stays open on click. * fix: drop 'Options' label from toolbar overflow menu --------- Co-authored-by: arkml <6592213+arkml@users.noreply.github.com> --- .../components/chat-input-with-mentions.tsx | 236 ++++++++++++++---- 1 file changed, 185 insertions(+), 51 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index a7d548ea..0254cdfd 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ArrowUp, @@ -19,6 +19,7 @@ import { ImagePlus, LoaderIcon, Mic, + MoreHorizontal, Plus, ShieldCheck, Square, @@ -29,6 +30,7 @@ import { import { Button } from '@/components/ui/button' import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, @@ -283,6 +285,51 @@ function ChatInputInner({ const [permissionMode, setPermissionMode] = useState('auto') const [recentWorkDirs, setRecentWorkDirs] = useState([]) + // Responsive toolbar: measure real overflow and progressively collapse items + // right→left until everything fits. Stages: + // 1 code→icon · 2 perm→icon · 3 search label hidden · 4 workDir→icon + // 5 code→menu · 6 perm→menu · 7 search→menu · 8 workDir→menu + // Once items move into the "⋯" overflow menu (≥5) no icon is ever hidden. + // overflow-hidden on the left group is the hard guarantee against any overlap. + const toolbarRef = useRef(null) + const leftGroupRef = useRef(null) + const lastWidthRef = useRef(0) + const [collapseLevel, setCollapseLevel] = useState(0) + + // Re-evaluate from scratch (level 0) whenever the available width changes… + useEffect(() => { + const outer = toolbarRef.current + if (!outer) return + const ro = new ResizeObserver(() => { + const w = outer.clientWidth + if (w !== lastWidthRef.current) { + lastWidthRef.current = w + setCollapseLevel(0) + } + }) + ro.observe(outer) + return () => ro.disconnect() + }, []) + + // …or when the *set* of items changes (an item appears/disappears, or the model + // name width changes). Deliberately excludes the in-place toggles (searchEnabled, + // permissionMode, codeModeEnabled, codingAgent): those fire from the overflow menu + // for items already inside it, so resetting here would unmount the open menu. The + // no-dep effect below still re-collapses if any toggle happens to widen the row. + useLayoutEffect(() => { + setCollapseLevel(0) + }, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey]) + + // After each render, if the left group still overflows, collapse one more step. + // Runs before paint, so the intermediate (overflowing) state is never visible. + useLayoutEffect(() => { + const el = leftGroupRef.current + if (!el) return + if (el.scrollWidth > el.clientWidth + 1 && collapseLevel < 8) { + setCollapseLevel((l) => Math.min(8, l + 1)) + } + }) + // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { if (!runId) { @@ -757,7 +804,8 @@ function ChatInputInner({ className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" /> -
+
+
@@ -862,26 +910,32 @@ function ChatInputInner({
- {workDir && ( + {workDir && collapseLevel < 8 && ( -
+ {/* Level 4: collapse to a square icon */} +
= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2" + )}> - + {collapseLevel < 4 && ( + + )}
@@ -889,7 +943,7 @@ function ChatInputInner({ )} - {searchAvailable && ( + {searchAvailable && collapseLevel < 7 && ( )} + {collapseLevel < 6 && ( @@ -943,37 +996,54 @@ function ChatInputInner({ : 'Manual approval prompts — click for auto-permission'} - {codeModeFeatureEnabled && (codeModeEnabled ? ( -
+ )} + {codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? ( + collapseLevel >= 1 ? ( + /* Level 1: collapse the pill to a single icon */ - Code mode on — click to disable + Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable - · - - - - - - Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap - - -
+ ) : ( +
+ + + + + Code mode on — click to disable + + · + + + + + + Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap + + +
+ ) ) : ( @@ -989,25 +1059,89 @@ function ChatInputInner({ Use a coding agent (Claude Code or Codex) ))} +
+ {collapseLevel >= 5 && ( + + + + + + + + More options + + + {workDir && collapseLevel >= 8 && ( + { void handleSetWorkDir() }}> + + {basename(workDir) || workDir} + + )} + {searchAvailable && collapseLevel >= 7 && ( + e.preventDefault()} + onCheckedChange={(c) => setSearchEnabled(Boolean(c))} + > + Web search + + )} + {collapseLevel >= 6 && ( + e.preventDefault()} + onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')} + > + Auto-approve actions + + )} + {codeModeFeatureEnabled && collapseLevel >= 5 && ( + <> + e.preventDefault()} + onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))} + > + Code mode + + {codeModeEnabled && ( + { e.preventDefault(); handleToggleCodingAgent() }}> + + Coding agent + {codingAgent === 'claude' ? 'Claude' : 'Codex'} + + )} + + )} + + + )}
{lockedModel ? ( - {getSelectedModelDisplayName(lockedModel.model)} + {getSelectedModelDisplayName(lockedModel.model)} ) : configuredModels.length > 0 ? (