Compare commits

...

4 commits
v0.5.5 ... main

Author SHA1 Message Date
Ramnique Singh
e2178c1488
Merge pull request #610 from rowboatlabs/dev
Dev
2026-06-08 19:47:49 +05:30
Ramnique Singh
f6f6c715a0 fix spacing 2026-06-08 19:37:12 +05:30
Ramnique Singh
8fb0833b19 upgrade gh actions pnpm 2026-06-08 19:37:05 +05:30
gagan
46042f9465
fix: keep chat input toolbar usable when the panel is narrow (#606)
* 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>
2026-06-08 02:10:23 +05:30
2 changed files with 206 additions and 72 deletions

View file

@ -16,9 +16,9 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v6
with: with:
version: 9 version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
@ -122,9 +122,9 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v6
with: with:
version: 9 version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
@ -187,9 +187,9 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v6
with: with:
version: 9 version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6

View file

@ -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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { import {
ArrowUp, ArrowUp,
@ -19,6 +19,7 @@ import {
ImagePlus, ImagePlus,
LoaderIcon, LoaderIcon,
Mic, Mic,
MoreHorizontal,
Plus, Plus,
ShieldCheck, ShieldCheck,
Square, Square,
@ -29,6 +30,7 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
@ -283,6 +285,51 @@ function ChatInputInner({
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto') const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([]) const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
// 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<HTMLDivElement>(null)
const leftGroupRef = useRef<HTMLDivElement>(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. // When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => { useEffect(() => {
if (!runId) { if (!runId) {
@ -757,7 +804,8 @@ function ChatInputInner({
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0" className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
/> />
</div> </div>
<div className="flex items-center gap-2 px-4 pb-3"> <div ref={toolbarRef} className="flex items-center gap-2 px-4 pb-3">
<div ref={leftGroupRef} className="flex min-w-0 items-center gap-2 overflow-hidden">
<DropdownMenu> <DropdownMenu>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -862,26 +910,32 @@ function ChatInputInner({
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{workDir && ( {workDir && collapseLevel < 8 && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"> {/* Level 4: collapse to a square icon */}
<div className={cn(
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2"
)}>
<button <button
type="button" type="button"
onClick={handleSetWorkDir} onClick={handleSetWorkDir}
className="flex min-w-0 items-center gap-1.5" className="flex min-w-0 items-center gap-1.5"
> >
<FolderCog className="h-3.5 w-3.5 shrink-0" /> <FolderCog className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{basename(workDir) || workDir}</span> {collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>}
</button>
<button
type="button"
onClick={handleClearWorkDir}
aria-label="Remove work directory"
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5 shrink-0" />
</button> </button>
{collapseLevel < 4 && (
<button
type="button"
onClick={handleClearWorkDir}
aria-label="Remove work directory"
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5 shrink-0" />
</button>
)}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
@ -889,7 +943,7 @@ function ChatInputInner({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{searchAvailable && ( {searchAvailable && collapseLevel < 7 && (
<button <button
type="button" type="button"
onClick={() => setSearchEnabled((v) => !v)} onClick={() => setSearchEnabled((v) => !v)}
@ -903,16 +957,14 @@ function ChatInputInner({
)} )}
> >
<Globe className="h-4 w-4 shrink-0" /> <Globe className="h-4 w-4 shrink-0" />
<span {searchEnabled && collapseLevel < 3 && (
className={cn( <span className="ml-1.5 whitespace-nowrap text-xs font-medium">
'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out', Search
searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0' </span>
)} )}
>
Search
</span>
</button> </button>
)} )}
{collapseLevel < 6 && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
@ -923,7 +975,8 @@ function ChatInputInner({
}} }}
disabled={Boolean(runId)} disabled={Boolean(runId)}
className={cn( className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors", "flex h-7 shrink-0 items-center gap-1.5 rounded-full text-xs font-medium transition-colors",
collapseLevel >= 2 ? "w-7 justify-center" : "px-2.5",
permissionMode === 'auto' permissionMode === 'auto'
? "bg-secondary text-foreground hover:bg-secondary/70" ? "bg-secondary text-foreground hover:bg-secondary/70"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "text-muted-foreground hover:bg-muted hover:text-foreground",
@ -931,8 +984,8 @@ function ChatInputInner({
)} )}
aria-label="Permission mode" aria-label="Permission mode"
> >
<ShieldCheck className="h-3.5 w-3.5" /> <ShieldCheck className="h-3.5 w-3.5 shrink-0" />
<span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span> {collapseLevel < 2 && <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">
@ -943,37 +996,54 @@ function ChatInputInner({
: 'Manual approval prompts — click for auto-permission'} : 'Manual approval prompts — click for auto-permission'}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{codeModeFeatureEnabled && (codeModeEnabled ? ( )}
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground"> {codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? (
collapseLevel >= 1 ? (
/* Level 1: collapse the pill to a single icon */
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
onClick={() => setCodeModeEnabled(false)} onClick={() => setCodeModeEnabled(false)}
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/70"
> >
<Terminal className="h-3.5 w-3.5" /> <Terminal className="h-3.5 w-3.5" />
<span>Code</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Code mode on click to disable</TooltipContent> <TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) click to disable</TooltipContent>
</Tooltip> </Tooltip>
<span className="text-foreground/30">·</span> ) : (
<Tooltip> <div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<TooltipTrigger asChild> <Tooltip>
<button <TooltipTrigger asChild>
type="button" <button
onClick={handleToggleCodingAgent} type="button"
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70" onClick={() => setCodeModeEnabled(false)}
> className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span> >
</button> <Terminal className="h-3.5 w-3.5" />
</TooltipTrigger> <span>Code</span>
<TooltipContent side="top"> </button>
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} click to swap </TooltipTrigger>
</TooltipContent> <TooltipContent side="top">Code mode on click to disable</TooltipContent>
</Tooltip> </Tooltip>
</div> <span className="text-foreground/30">·</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleCodingAgent}
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
>
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} click to swap
</TooltipContent>
</Tooltip>
</div>
)
) : ( ) : (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -989,25 +1059,89 @@ function ChatInputInner({
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent> <TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
</Tooltip> </Tooltip>
))} ))}
</div>
{collapseLevel >= 5 && (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="More options"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">More options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top" className="min-w-52">
{workDir && collapseLevel >= 8 && (
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
<FolderCog className="size-4" />
<span className="min-w-0 flex-1 truncate">{basename(workDir) || workDir}</span>
</DropdownMenuItem>
)}
{searchAvailable && collapseLevel >= 7 && (
<DropdownMenuCheckboxItem
checked={searchEnabled}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setSearchEnabled(Boolean(c))}
>
Web search
</DropdownMenuCheckboxItem>
)}
{collapseLevel >= 6 && (
<DropdownMenuCheckboxItem
checked={permissionMode === 'auto'}
disabled={Boolean(runId)}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setPermissionMode(c ? 'auto' : 'manual')}
>
Auto-approve actions
</DropdownMenuCheckboxItem>
)}
{codeModeFeatureEnabled && collapseLevel >= 5 && (
<>
<DropdownMenuCheckboxItem
checked={codeModeEnabled}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))}
>
Code mode
</DropdownMenuCheckboxItem>
{codeModeEnabled && (
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
<Terminal className="size-4" />
<span className="min-w-0 flex-1">Coding agent</span>
<span className="text-xs text-muted-foreground">{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="flex-1" /> <div className="flex-1" />
{lockedModel ? ( {lockedModel ? (
<span <span
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground" className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`} title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
> >
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span> <span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
</span> </span>
) : configuredModels.length > 0 ? ( ) : configuredModels.length > 0 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
> >
<span className="max-w-[150px] truncate"> <span className="min-w-0 truncate">
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')} {getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
</span> </span>
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3 shrink-0" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">