mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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.
This commit is contained in:
parent
77aa2e6585
commit
49cc4f4514
1 changed files with 71 additions and 25 deletions
|
|
@ -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,
|
||||||
|
|
@ -282,6 +282,46 @@ function ChatInputInner({
|
||||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||||
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 collapse items to icons
|
||||||
|
// right→left (1=code, 2=perm, 3=search-label, 4=workDir) until everything fits.
|
||||||
|
// overflow-hidden on the left group is the hard guarantee that nothing can ever
|
||||||
|
// overlap; the measurement just decides how much to collapse before that clip.
|
||||||
|
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/labels of items changes (workdir name, search/code toggles).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setCollapseLevel(0)
|
||||||
|
}, [workDir, searchEnabled, searchAvailable, codeModeEnabled, 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 < 4) {
|
||||||
|
setCollapseLevel((l) => Math.min(4, 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) {
|
||||||
|
|
@ -653,7 +693,7 @@ function ChatInputInner({
|
||||||
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
|
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rowboat-chat-input @container rounded-lg border border-border bg-background shadow-none">
|
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||||
{attachments.map((attachment) => {
|
{attachments.map((attachment) => {
|
||||||
|
|
@ -756,8 +796,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 className="flex min-w-0 flex-1 items-center gap-2">
|
<div ref={leftGroupRef} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -865,24 +905,29 @@ function ChatInputInner({
|
||||||
{workDir && (
|
{workDir && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
{/* Collapses to a square icon below ~370px container width */}
|
{/* Level 4: collapse to a square icon */}
|
||||||
<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 @max-[370px]:w-7 @max-[370px]:max-w-7 @max-[370px]:justify-center @max-[370px]:px-0">
|
<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 @max-[370px]:hidden">{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 @max-[370px]:hidden"
|
|
||||||
>
|
|
||||||
<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">
|
||||||
|
|
@ -904,8 +949,8 @@ function ChatInputInner({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4 shrink-0" />
|
<Globe className="h-4 w-4 shrink-0" />
|
||||||
{searchEnabled && (
|
{searchEnabled && collapseLevel < 3 && (
|
||||||
<span className="ml-1.5 whitespace-nowrap text-xs font-medium @max-[410px]:hidden">
|
<span className="ml-1.5 whitespace-nowrap text-xs font-medium">
|
||||||
Search
|
Search
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -921,7 +966,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 @max-[460px]:w-7 @max-[460px]:justify-center @max-[460px]:px-0",
|
"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",
|
||||||
|
|
@ -930,7 +976,7 @@ function ChatInputInner({
|
||||||
aria-label="Permission mode"
|
aria-label="Permission mode"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="@max-[460px]:hidden">{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>
|
{collapseLevel < 2 && <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
|
|
@ -942,22 +988,22 @@ function ChatInputInner({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
||||||
<>
|
collapseLevel >= 1 ? (
|
||||||
{/* Compact icon — shown below ~560px when there's no room for the full pill */}
|
/* 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="hidden h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/70 @max-[560px]:flex"
|
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" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable</TooltipContent>
|
<TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* Full pill — hidden below ~560px */}
|
) : (
|
||||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground @max-[560px]:hidden">
|
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -987,7 +1033,7 @@ function ChatInputInner({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)
|
||||||
) : (
|
) : (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue