From 1b7e0e57401b8c219e9833b287945865b8db1329 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 13 Jul 2026 14:43:31 -0300 Subject: [PATCH 001/440] fix(composer): portal access menu and ring-only context meter P0: AccessSelector popover portals to body so overflow:hidden no longer clips permissions. P1: Context meter is a fill ring only; ContextPanel disconnected from composer (file kept). --- src/renderer/App.tsx | 122 +----------------- .../features/access/AccessSelector.tsx | 56 +++++++- .../features/context/ContextMeter.tsx | 33 +++-- src/renderer/styles/composer.css | 74 ++++------- 4 files changed, 97 insertions(+), 188 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 08fb4e1c..9b716b5e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,4 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ForwardedRef, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react' -import { createPortal } from 'react-dom' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react' import { ArrowDown, CheckCircle2, ChevronDown, ChevronRight, FolderClosed, GitBranch, LoaderCircle, X, XCircle } from 'lucide-react' import type { AccessMode, @@ -71,7 +70,7 @@ import type { ExtractionStatus, ModelReasoning, VisionFallbackConsent, VisionFal import { recognizeImage } from './features/ocr/ocrService' import { Composer } from './features/composer/Composer' import { ContextMeter } from './features/context/ContextMeter' -import { ContextPanel, estimateTotalContextTokens } from './features/context/ContextPanel' +import { estimateTotalContextTokens } from './features/context/ContextPanel' import { TokenRateMeter } from './features/context/TokenRateMeter' import { FeedbackDialog } from './features/feedback/FeedbackDialog' import { ModelSelector } from './features/models/ModelSelector' @@ -80,7 +79,6 @@ import { ProfileView } from './features/profile/ProfileView' import { ProjectPicker } from './features/projects/ProjectPicker' import { SettingsView } from './features/settings/SettingsView' import mascotUrl from '../../assets/branding/verboo-mascot.png' -import { useOutsideDismiss } from './hooks/useOutsideDismiss' import { I18nProvider, createTranslator, useI18n, type Translator } from './i18n' import { DEFAULT_CONVERSATION_TITLE, @@ -243,80 +241,6 @@ function firstUsableWorkspaceDirectory(...paths: Array): str return paths.find(isUsableWorkspaceDirectory) ?? '' } -// Imperative handle returned from ContextPanelPortal to its parent. Parent -// (App.tsx) uses it to trigger an animated close from the meter toggle and -// from the ContextPanel's own close button — without that, those handlers -// would unmount the portal instantly instead of letting the close-dur -// transition play. -export type ContextPanelHandle = { - requestClose: () => void -} - -const ContextPanelPortal = forwardRef void - ignoreRefs?: React.RefObject[] - children: React.ReactNode -}>(function ContextPanelPortal({ pos, onClose, ignoreRefs, children }, ref) { - const divRef = useRef(null) - // Mount closed, then open on the next frame so the t-dropdown scale/opacity - // transition plays — the panel grows out of the meter in the composer. - const [open, setOpen] = useState(false) - const [closing, setClosing] = useState(false) - useEffect(() => { - const frame = requestAnimationFrame(() => setOpen(true)) - return () => cancelAnimationFrame(frame) - }, []) - - // Single source of truth for animated close. Sets is-closing (CSS picks - // the close-dur transition), then waits for the opacity/transform - // transition to end. Falls back to a 200ms safety timeout in case the - // browser never fires transitionend (e.g. display:none mid-transition). - // Only then does it call the parent's onClose, which unmounts the portal. - const handleClose = useCallback(() => { - if (closing) return - setClosing(true) - const el = divRef.current - let done = false - const finish = () => { - if (done) return - done = true - onClose() - } - const onEnd = (event: TransitionEvent) => { - if (event.target !== el) return - if (event.propertyName !== 'opacity' && event.propertyName !== 'transform') return - el?.removeEventListener('transitionend', onEnd) - finish() - } - el?.addEventListener('transitionend', onEnd) - setTimeout(finish, 200) - }, [closing, onClose]) - - // Imperative API for parent (meter toggle + ContextPanel close button). - useImperativeHandle(ref, () => ({ requestClose: handleClose }), [handleClose]) - useOutsideDismiss(divRef, true, handleClose, ignoreRefs) - return ( -
- {children} -
- ) -}) - export function App() { const initialSidebarPreference = useRef(readSidebarPreference()) const defaultWorkingDirectoryRef = useRef('') @@ -408,19 +332,12 @@ export function App() { const [subagentSummaryExpanded, setSubagentSummaryExpanded] = useState(false) const [feedbackOpen, setFeedbackOpen] = useState(false) const [contextUsage, setContextUsage] = useState() - const [contextPanelOpen, setContextPanelOpen] = useState(false) - const [contextMeterPos, setContextMeterPos] = useState<{ top: number; right: number } | undefined>() // Context windows the CLI itself reported via result.modelUsage — the Verboo // Router omits contextWindow from model discovery, so this is often the only // authoritative source. Persisted so the meter works from app launch. const [reportedContextWindows, setReportedContextWindows] = useState>( readReportedContextWindows, ) - const contextMeterRef = useRef(null) - // Imperative handle for the ContextPanel popover so the meter toggle and the - // ContextPanel's own ✕ button trigger the animated close (otherwise they - // would unmount the popover instantly and skip the close-dur transition). - const contextPanelRef = useRef(null) const [goal, setGoal] = useState() const [imageReadingTurnId, setImageReadingTurnId] = useState() const [sidebarMode, setSidebarMode] = useState(initialSidebarPreference.current.mode) @@ -4422,40 +4339,7 @@ export function App() { rightToolbar={ <> - - { - if (!contextMeterRef.current) return - if (contextPanelOpen) { - // Animated close: play close-dur transition before unmounting. - contextPanelRef.current?.requestClose() - return - } - const rect = contextMeterRef.current.getBoundingClientRect() - setContextMeterPos({ top: rect.top, right: rect.right }) - setContextPanelOpen(true) - }} /> - - {contextPanelOpen && contextMeterPos && createPortal( - setContextPanelOpen(false)} - ignoreRefs={[contextMeterRef]} - > - setAttachedFiles([])} - onClearSkills={() => setSelectedSkills([])} - onClose={() => contextPanelRef.current?.requestClose()} - /> - , - document.body - )} + (null) const wrapRef = useRef(null) + const pillRef = useRef(null) + const menuRef = useRef(null) const options = useMemo(() => [ { id: 'approval', @@ -43,7 +47,35 @@ export function AccessSelector({ value, fullAccessEnabled, onChange, onRequestFu }, ], [t]) const current = options.find(option => option.id === value)! - useOutsideDismiss(wrapRef, open, () => setOpen(false)) + // Portal sits outside wrapRef — treat pill + menu as the dismiss boundary. + useOutsideDismiss(wrapRef, open, () => setOpen(false), [menuRef]) + + // Portal to document.body so `.composer { overflow: hidden }` cannot clip the + // upward menu. Anchor ABOVE the pill (CSS `bottom` + `left`), mirroring + // ModelSelector but anchored to the left edge since access sits on the + // left side of the composer toolbar. + useLayoutEffect(() => { + if (!open) { + setMenuPos(null) + return + } + const pill = pillRef.current + if (!pill) return + const compute = () => { + const rect = pill.getBoundingClientRect() + setMenuPos({ + bottom: window.innerHeight - rect.top + 10, + left: Math.max(8, rect.left), + }) + } + compute() + window.addEventListener('resize', compute) + window.addEventListener('scroll', compute, true) + return () => { + window.removeEventListener('resize', compute) + window.removeEventListener('scroll', compute, true) + } + }, [open]) function choose(option: AccessOption) { if (option.id === 'full' && !fullAccessEnabled) { @@ -57,13 +89,24 @@ export function AccessSelector({ value, fullAccessEnabled, onChange, onRequestFu return (
- - {open && ( -
+ {open && menuPos && createPortal( +
{t('access.heading')} {t('access.learnMore')} @@ -89,7 +132,8 @@ export function AccessSelector({ value, fullAccessEnabled, onChange, onRequestFu ) })} -
+
, + document.body, )}
diff --git a/src/renderer/features/context/ContextMeter.tsx b/src/renderer/features/context/ContextMeter.tsx index 0648566f..b7f7d925 100644 --- a/src/renderer/features/context/ContextMeter.tsx +++ b/src/renderer/features/context/ContextMeter.tsx @@ -1,4 +1,3 @@ -import { AlertTriangle, Gauge } from 'lucide-react' import type { CSSProperties } from 'react' import type { ContextUsageSnapshot } from '../../../shared/types' import { formatCompactNumber, useI18n } from '../../i18n' @@ -6,10 +5,21 @@ import { formatCompactNumber, useI18n } from '../../i18n' type ContextMeterProps = { usage?: ContextUsageSnapshot contextWindow?: number - onClick?: () => void } -export function ContextMeter({ usage, contextWindow, onClick }: ContextMeterProps) { +/** + * Ring-only context meter for the composer toolbar. + * + * Design (Codex-like): a single filled ring that grows with context usage — + * no rotation, no dropdown, no panel on click. The percent label sits in the + * ring center; the full `used/max` breakdown is exposed via the native + * `title` tooltip so it stays discoverable without adding composer chrome. + * + * The previous `onClick` / ContextPanel popover was disconnected from the + * composer (panel file retained for future Settings reuse). Pruning actions + * (clear attachments / skills) remain available in their own surfaces. + */ +export function ContextMeter({ usage, contextWindow }: ContextMeterProps) { const { language, t } = useI18n() const maxTokens = usage?.maxTokens ?? contextWindow const usedTokens = usage?.usedTokens @@ -31,23 +41,20 @@ export function ContextMeter({ usage, contextWindow, onClick }: ContextMeterProp const title = overLimit ? t('context.overLimitTitle') : usage ? t('context.usageTitle') : t('context.waitingTitle') + // Compose a single informative tooltip: title + usage breakdown. + const tooltip = `${title} · ${usageLabel} · ${percentLabel}` return ( - +
) } diff --git a/src/renderer/styles/composer.css b/src/renderer/styles/composer.css index 7f77c459..c302a450 100644 --- a/src/renderer/styles/composer.css +++ b/src/renderer/styles/composer.css @@ -1026,6 +1026,19 @@ max-height: min(420px, calc(100vh - 220px)); } +/* Portaled above the composer so overflow:hidden cannot clip the menu. + Mirrors `.model-menu.model-menu-portal`. Inline style fixes position to + the pill's bounding rect; this class handles z-index, sizing, and bounds. */ +.access-menu.access-menu-portal { + z-index: 10000; + width: min(480px, calc(100vw - 24px)); + min-width: min(360px, calc(100vw - 24px)); + max-height: min(420px, calc(100vh - 120px)); + overflow-x: hidden; + overflow-y: auto; + box-sizing: border-box; +} + .model-menu-status { display: grid; gap: 2px; @@ -1305,23 +1318,22 @@ } .context-meter { - display: inline-grid; - grid-template-columns: auto minmax(0, 1fr) auto; + display: inline-flex; align-items: center; - flex: 0 1 164px; - gap: 7px; + flex: 0 0 auto; height: 34px; - min-width: 164px; - max-width: 190px; - padding: 0 7px 0 9px; - border: 1px solid color-mix(in srgb, var(--border-strong) 76%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--bg-soft) 48%, transparent); + padding: 0; + border: none; + background: transparent; color: var(--text-muted); - cursor: pointer; font: inherit; } -.context-meter:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; } + +/* Ring-only variant: no pill chrome, no chevron, no select-like width. + The ring fills (conic-gradient) as context grows — Codex-like. */ +.context-meter--ring-only { + cursor: default; +} @media (max-width: 1180px) { .composer-tools.right { @@ -1344,51 +1356,13 @@ font-size: 9.5px; } - .context-meter { - min-width: 132px; - max-width: 154px; - } - .model-pill { min-width: 126px; max-width: 172px; } } -.context-meter-icon { - color: var(--accent-strong); -} - -.context-copy { - display: grid; - gap: 0; - min-width: 0; - line-height: 1.05; -} - -.context-copy strong { - color: var(--text); - font-size: 11.5px; - font-weight: 780; -} - -.context-copy small { - overflow: hidden; - color: var(--text-dim); - font-size: 10.5px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - .context-meter.over-limit { - border-color: color-mix(in srgb, var(--danger) 42%, var(--border)); - color: var(--danger); - background: color-mix(in srgb, var(--danger) 9%, transparent); -} - -.context-meter.over-limit .context-meter-icon, -.context-meter.over-limit .context-copy strong { color: var(--danger); } From cd681804273652db9d147f6d54bdd11e1a5f01e1 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 13 Jul 2026 14:57:55 -0300 Subject: [PATCH 002/440] feat(composer): Codex-style model selector rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace flat model list + search with hierarchical Modelo/Esforço drill-in panels. Soften model pill tone; keep body portal; update ModelSelector tests (12). --- .../features/models/ModelSelector.test.tsx | 143 ++++++--- .../features/models/ModelSelector.tsx | 298 +++++++++++------- src/renderer/i18n.tsx | 8 + src/renderer/styles/composer.css | 124 ++++---- 4 files changed, 353 insertions(+), 220 deletions(-) diff --git a/src/renderer/features/models/ModelSelector.test.tsx b/src/renderer/features/models/ModelSelector.test.tsx index 56ad7801..1089043b 100644 --- a/src/renderer/features/models/ModelSelector.test.tsx +++ b/src/renderer/features/models/ModelSelector.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, cleanup, within } from '@testing-library/react' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' // @lobehub/icons transitively imports @lobehub/fluent-emoji, whose ESM is // not resolvable in jsdom. Mock ModelIcon before importing ModelSelector. @@ -8,22 +8,22 @@ vi.mock('./ModelIcon', () => ({ ModelIcon: () => null })) import type { ModelDiscoveryResult, VerbooModel } from '../../../shared/types' import { ModelSelector } from './ModelSelector' -/** Footer queries must be scoped — pill text "Ultra · Max" would otherwise - * collide with the level button "Max". */ -function footer() { - const label = screen.getByText(/Nível de raciocínio|Reasoning effort/i) - return within(label.closest('.model-menu-effort-footer')!) -} - /** - * Regression tests for the Option B refactor of ModelSelector. + * Regression tests for the Codex-style ModelSelector refactor. * - * The footer is the single source of truth for reasoning effort: - * - Renders only when the selected model exposes `effortLevels`. - * - Shows "Usar padrão" + every dynamic level (no hardcoded list). - * - "Usar padrão" clears the override; a level click persists it. - * - Stale overrides (not in current effortLevels) fall back to default. - * - No per-row arrow or submenu anywhere. + * Root menu shows rows (Model / Effort). Effort levels live behind a + * drill-in panel (`.model-effort-list`), not an inline footer. + * + * Coverage preserved from the footer-era tests: + * - Pill label "Model · effort" uses effective effort. + * - Effort row only renders when selected model exposes effortLevels. + * - Dynamic levels (no hardcoded list). + * - "Usar padrão" selected when no override; level selected when override exists. + * - Stale override falls back to "Usar padrão". + * - "none" tier handled. + * - onSelectEffort / onClearEffortOverride wired. + * - No legacy per-row arrow/submenu classes. + * - Effort buttons are focusable. */ const baseModel: VerbooModel = { @@ -74,11 +74,19 @@ function openMenu() { fireEvent.click(Pill()) } +/** Open the effort drill-in panel from the root menu. */ +function openEffortPanel() { + openMenu() + // Click the "Esforço" row (labelled with the effort row label). + const effortRow = screen.getByText(/Esforço|Effort/i).closest('button')! + fireEvent.click(effortRow) +} + beforeEach(() => { cleanup() }) -describe('ModelSelector — effort footer (Option B)', () => { +describe('ModelSelector — Codex rows + effort drill-in', () => { it('renders pill label "Model · effort" using effective effort', () => { render( { expect(Pill()).toHaveTextContent(/Ultra.*·.*Alto|Máximo|High|Max/) }) - it('does NOT render the footer when selected model has no reasoning capability', () => { + it('does NOT render the effort row when selected model has no reasoning capability', () => { render( { />, ) openMenu() - expect(screen.queryByText(/Nível de raciocínio|Reasoning effort/i)).toBeNull() + // Root menu shows the Model row but no Effort row. + expect(screen.queryByText(/Esforço|Effort/i)).toBeNull() expect(screen.queryByText(/Usar padrão|Use default/i)).toBeNull() }) @@ -127,15 +136,19 @@ describe('ModelSelector — effort footer (Option B)', () => { selectedEffort="medium" />, ) - openMenu() - const f = footer() - // Every dynamic level rendered in the footer - expect(f.getByText(/Nenhum|None/i)).toBeTruthy() - expect(f.getByText(/Baixo|Low/i)).toBeTruthy() - expect(f.getByText(/Médio|Medium/i)).toBeTruthy() - expect(f.getByText(/Alto|High/i)).toBeTruthy() - // No phantom Máximo/Max — qwen3 does not offer it - expect(f.queryByText(/Máximo|Max/i)).toBeNull() + openEffortPanel() + // Every dynamic level rendered in the effort list (scoped to effort + // buttons to avoid collision with the pill text "Qwen3 · Médio"). + const effortButtons = screen.getAllByRole('button').filter(btn => + btn.classList.contains('model-effort-option'), + ) + const labels = effortButtons.map(btn => btn.textContent ?? '') + expect(labels.some(l => /Nenhum|None/i.test(l))).toBe(true) + expect(labels.some(l => /Baixo|Low/i.test(l))).toBe(true) + expect(labels.some(l => /Médio|Medium/i.test(l))).toBe(true) + expect(labels.some(l => /Alto|High/i.test(l))).toBe(true) + // No phantom Máximo/Max — qwen3 does not offer it. + expect(labels.some(l => /Máximo|Max/i.test(l))).toBe(false) }) it('highlights "Usar padrão" when no override is saved (default in effect)', () => { @@ -151,12 +164,11 @@ describe('ModelSelector — effort footer (Option B)', () => { selectedEffort="high" />, ) - openMenu() - const f = footer() - const useDefault = f.getByText(/Usar padrão|Use default/i).closest('button')! + openEffortPanel() + const useDefault = screen.getByText(/Usar padrão|Use default/i).closest('button')! expect(useDefault).toHaveClass('selected') // High is NOT marked selected — it coincides with default but isn't an override. - const highBtn = f.getByText(/Alto|High/i).closest('button')! + const highBtn = screen.getByText(/^Alto$|^High$/i).closest('button')! expect(highBtn).not.toHaveClass('selected') }) @@ -173,11 +185,14 @@ describe('ModelSelector — effort footer (Option B)', () => { selectedEffort="max" />, ) - openMenu() - const f = footer() - const useDefault = f.getByText(/Usar padrão|Use default/i).closest('button')! + openEffortPanel() + const useDefault = screen.getByText(/Usar padrão|Use default/i).closest('button')! expect(useDefault).not.toHaveClass('selected') - const maxBtn = f.getByText(/Máximo|Max/i).closest('button')! + // Scope to effort buttons — the pill also shows "Ultra · Máximo". + const effortButtons = screen.getAllByRole('button').filter(btn => + btn.classList.contains('model-effort-option'), + ) + const maxBtn = effortButtons.find(btn => /Máximo|Max/i.test(btn.textContent ?? ''))! expect(maxBtn).toHaveClass('selected') }) @@ -196,12 +211,11 @@ describe('ModelSelector — effort footer (Option B)', () => { selectedEffort="high" />, ) - openMenu() - const f = footer() - const useDefault = f.getByText(/Usar padrão|Use default/i).closest('button')! + openEffortPanel() + const useDefault = screen.getByText(/Usar padrão|Use default/i).closest('button')! expect(useDefault).toHaveClass('selected') // No "Máximo" rendered at all because it's not in the dynamic levels. - expect(f.queryByText(/Máximo|Max/i)).toBeNull() + expect(screen.queryByText(/Máximo|Max/i)).toBeNull() }) it('handles "none" tier when the router advertises it', () => { @@ -217,8 +231,8 @@ describe('ModelSelector — effort footer (Option B)', () => { selectedEffort="medium" />, ) - openMenu() - expect(footer().getByText(/Nenhum|None/i)).toBeTruthy() + openEffortPanel() + expect(screen.getByText(/Nenhum|None/i)).toBeTruthy() }) it('calls onSelectEffort when a level is clicked and closes the menu', () => { @@ -236,10 +250,10 @@ describe('ModelSelector — effort footer (Option B)', () => { onSelectEffort={onSelectEffort} />, ) - openMenu() - fireEvent.click(footer().getByText(/Máximo|Max/i)) + openEffortPanel() + fireEvent.click(screen.getByText(/Máximo|Max/i)) expect(onSelectEffort).toHaveBeenCalledWith('glm-5.2', 'max') - // Menu closed (footer unmounted) + // Menu closed (effort list unmounted) expect(screen.queryByText(/Usar padrão|Use default/i)).toBeNull() }) @@ -258,12 +272,12 @@ describe('ModelSelector — effort footer (Option B)', () => { onClearEffortOverride={onClearEffortOverride} />, ) - openMenu() - fireEvent.click(footer().getByText(/Usar padrão|Use default/i)) + openEffortPanel() + fireEvent.click(screen.getByText(/Usar padrão|Use default/i)) expect(onClearEffortOverride).toHaveBeenCalledWith('glm-5.2') }) - it('does not render any per-row effort arrow or submenu chevron', () => { + it('does not render any legacy per-row effort arrow or submenu classes', () => { const { container } = render( { expect(container.querySelector('.model-option-effort-arrow')).toBeNull() expect(container.querySelector('.model-effort-submenu')).toBeNull() expect(container.querySelector('.model-option-wrap')).toBeNull() + // The old inline footer must not be present either. + expect(container.querySelector('.model-menu-effort-footer')).toBeNull() }) - it('footer is keyboard-reachable: each level is a focusable button', () => { + it('effort panel buttons are keyboard-reachable: each level is a focusable button', () => { render( { selectedEffort="high" />, ) - openMenu() + openEffortPanel() // "Usar padrão" + 3 levels = 4 effort buttons total. - const footerButtons = screen.getAllByRole('button').filter(btn => + const effortButtons = screen.getAllByRole('button').filter(btn => btn.classList.contains('model-effort-option'), ) - expect(footerButtons.length).toBe(4) - for (const btn of footerButtons) { + expect(effortButtons.length).toBe(4) + for (const btn of effortButtons) { expect(btn.tagName).toBe('BUTTON') // TabIndex default (0) — focusable via Tab. expect(btn).not.toHaveAttribute('tabindex', '-1') } }) + + it('Escape from effort panel returns to root (does not close menu)', () => { + render( + {}} + onRefresh={() => {}} + effortByModel={{}} + selectedEffortLevels={['low', 'high', 'max']} + selectedEffort="high" + />, + ) + openEffortPanel() + // Effort list visible. + expect(screen.getByText(/Usar padrão|Use default/i)).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + // Back to root — effort list gone, Model row visible again (the row + // label "Modelo" coexists with the popover title, so getAllByText). + expect(screen.queryByText(/Usar padrão|Use default/i)).toBeNull() + expect(screen.getAllByText(/Modelo|Model/i).length).toBeGreaterThan(0) + }) }) diff --git a/src/renderer/features/models/ModelSelector.tsx b/src/renderer/features/models/ModelSelector.tsx index 859d331d..e323492d 100644 --- a/src/renderer/features/models/ModelSelector.tsx +++ b/src/renderer/features/models/ModelSelector.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, Eye, RefreshCw, Search } from 'lucide-react' +import { Check, ChevronDown, ChevronRight, Eye, RefreshCw, Search } from 'lucide-react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties } from 'react' import { createPortal } from 'react-dom' @@ -6,7 +6,7 @@ import type { ModelDiscoveryResult, VerbooModel } from '../../../shared/types' import { formatCompactNumber, useI18n } from '../../i18n' import { ModelIcon } from './ModelIcon' -const SEARCH_THRESHOLD = 6 +const SEARCH_THRESHOLD = 12 type ModelSelectorProps = { models: VerbooModel[] @@ -50,6 +50,8 @@ function effortLabel(level: string, t: (key: string) => string): string { export function ModelSelector({ models, selectedModel, hasConversationHistory = false, modelResult, onSelect, onRefresh, effortByModel, selectedEffortLevels = [], selectedEffort, onSelectEffort, onClearEffortOverride }: ModelSelectorProps) { const { language, t } = useI18n() const [open, setOpen] = useState(false) + // Drill-in panel: 'root' | 'models' | 'effort' + const [panel, setPanel] = useState<'root' | 'models' | 'effort'>('root') const [query, setQuery] = useState('') const [highlighted, setHighlighted] = useState(0) const [menuPos, setMenuPos] = useState<{ bottom: number; right: number } | null>(null) @@ -58,10 +60,10 @@ export function ModelSelector({ models, selectedModel, hasConversationHistory = const menuRef = useRef(null) const searchRef = useRef(null) const selected = models.find(model => model.id === selectedModel) - const showSearch = models.length > SEARCH_THRESHOLD + const showSearch = panel === 'models' && models.length > SEARCH_THRESHOLD // Override is only "valid" when it's still in the model's current // effortLevels. A stale value (model changed its levels) falls back to - // defaultEffort, surfaced as "Usar padrão" selected in the footer. + // defaultEffort, surfaced as "Usar padrão" selected in the effort panel. const hasEffortOverride = Boolean( selected && effortByModel?.[selected.id] @@ -80,6 +82,7 @@ export function ModelSelector({ models, selectedModel, hasConversationHistory = const activeIndex = flat.length ? Math.min(highlighted, flat.length - 1) : 0 const selectedTone = selected ? modelToneStyle(selected.id) : undefined const statusMessage = modelStatusMessage(modelResult, t) + const showEffortRow = Boolean(selected && selectedEffortLevels.length > 0) // Portal sits outside wrapRef — treat pill + menu as the dismiss boundary. useEffect(() => { if (!open) return @@ -91,7 +94,13 @@ export function ModelSelector({ models, selectedModel, hasConversationHistory = setOpen(false) } function handleKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') setOpen(false) + if (event.key === 'Escape') { + if (panel !== 'root') { + setPanel('root') + return + } + setOpen(false) + } } document.addEventListener('pointerdown', handlePointerDown, true) document.addEventListener('keydown', handleKeyDown, true) @@ -99,13 +108,13 @@ export function ModelSelector({ models, selectedModel, hasConversationHistory = document.removeEventListener('pointerdown', handlePointerDown, true) document.removeEventListener('keydown', handleKeyDown, true) } - }, [open]) + }, [open, panel]) useEffect(() => { if (!open) return + setPanel('root') setQuery('') setHighlighted(Math.max(0, flat.findIndex(model => model.id === selectedModel))) - searchRef.current?.focus() // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) @@ -140,7 +149,7 @@ export function ModelSelector({ models, selectedModel, hasConversationHistory = } function handleSearchKeyDown(event: React.KeyboardEvent) { - if (event.key === 'Escape') { setOpen(false); return } + if (event.key === 'Escape') { setPanel('root'); return } if (!flat.length) return if (event.key === 'ArrowDown') { event.preventDefault() @@ -180,124 +189,183 @@ export function ModelSelector({ models, selectedModel, hasConversationHistory = left: 'auto', }} > -
- {t('model.label')} - -
+ {panel === 'root' && ( + <> +
+ {t('model.label')} + +
- {showSearch && ( -
-
- )} + {statusMessage && ( +
0 ? 'subtle' : ''}`}> + {statusMessage} + {modelResult.stale && models.length > 0 && {t('model.usingSaved')}} +
+ )} - {statusMessage && ( -
0 ? 'subtle' : ''}`}> - {statusMessage} - {modelResult.stale && models.length > 0 && {t('model.usingSaved')}} -
+ {hasConversationHistory && ( +
+ {t('model.switchWarning')} +
+ )} + +
+ + + {showEffortRow && ( + + )} +
+ )} - {hasConversationHistory && ( -
- {t('model.switchWarning')} -
+ {panel === 'models' && ( + <> +
+ + {t('model.row.model')} + +
+ + {showSearch && ( +
+
+ )} + + {flat.length === 0 ? ( +
{t('model.empty')}
+ ) : ( + grouped.map(group => ( +
+
{group.label}
+ {group.models.map(model => { + const index = flat.indexOf(model) + return ( + + ) + })} +
+ )) + )} + )} - {flat.length === 0 ? ( -
{t('model.empty')}
- ) : ( - grouped.map(group => ( -
-
{group.label}
- {group.models.map(model => { - const index = flat.indexOf(model) + {panel === 'effort' && selected && ( + <> +
+ + {t('model.row.effort')} +
+ +
+ + {selectedEffortLevels.map(level => { + const isSelected = hasEffortOverride && effortByModel?.[selected.id] === level return ( ) })}
- )) - )} - - {selected && selectedEffortLevels.length > 0 && ( - <> -
-
- {t('composer.effortTitle')} -
- - {selectedEffortLevels.map(level => { - const isSelected = hasEffortOverride && effortByModel?.[selected.id] === level - return ( - - ) - })} -
-
)}
, @@ -311,9 +379,9 @@ function modelToneStyle(modelId: string): CSSProperties { const hues = [266, 194, 146, 318, 28, 218] const hue = hues[hashString(modelId) % hues.length] return { - '--model-color': `hsl(${hue} 92% 68%)`, - '--model-bg': `hsl(${hue} 88% 60% / 0.14)`, - '--model-border': `hsl(${hue} 88% 65% / 0.42)`, + '--model-color': `hsl(${hue} 72% 66%)`, + '--model-bg': `hsl(${hue} 60% 58% / 0.08)`, + '--model-border': `hsl(${hue} 48% 60% / 0.22)`, } as CSSProperties } diff --git a/src/renderer/i18n.tsx b/src/renderer/i18n.tsx index 75fda9a1..9b178c04 100644 --- a/src/renderer/i18n.tsx +++ b/src/renderer/i18n.tsx @@ -295,6 +295,10 @@ const enUS: Record = { 'model.group.available': 'Available', 'model.group.longContext': 'Long context', 'model.contextSuffix': 'ctx', + 'model.row.model': 'Model', + 'model.row.effort': 'Effort', + 'model.row.advanced': 'Advanced', + 'model.row.effortDefault': 'Default', 'context.label': 'Context', 'context.window': 'window {value}', @@ -1146,6 +1150,10 @@ const ptBR: Record = { 'model.group.available': 'Disponíveis', 'model.group.longContext': 'Contexto longo', 'model.contextSuffix': 'ctx', + 'model.row.model': 'Modelo', + 'model.row.effort': 'Esforço', + 'model.row.advanced': 'Avançado', + 'model.row.effortDefault': 'Padrão', 'context.label': 'Contexto', 'context.window': 'janela {value}', diff --git a/src/renderer/styles/composer.css b/src/renderer/styles/composer.css index c302a450..d7d835d4 100644 --- a/src/renderer/styles/composer.css +++ b/src/renderer/styles/composer.css @@ -567,78 +567,96 @@ background: var(--model-bg, transparent); } -/* Effort footer section at the bottom of the model menu. Single dynamic - surface: shows "Usar padrão" + the selected model's effortLevels. No - per-row submenu or duplicated controls. */ -.model-menu-divider { - margin: 6px 8px; - border-top: 1px solid var(--border); +/* Codex-style root menu: clean rows instead of a flat list + footer. + Each row is a drill-in affordance (Model / Effort). */ +.model-rows { + display: grid; + gap: 2px; + padding: 2px 4px; } -.model-menu-effort-footer { - padding: 8px 12px; - display: flex; - flex-direction: column; - gap: 6px; +.model-row { + display: grid; + grid-template-columns: minmax(72px, auto) minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 10px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; + transition: background 90ms ease; } -.model-effort-footer-label { - font-size: 11px; - font-weight: 600; +.model-row:hover { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.model-row-label { color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.04em; + font-size: 12px; + font-weight: 720; } -.model-effort-footer-levels { - display: flex; - flex-wrap: wrap; - gap: 3px; +.model-row-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + font-size: 12.5px; + font-weight: 640; } -.model-effort-option, -.model-effort-footer-levels button { - display: inline-flex; +.model-row-chevron { + color: var(--text-dim); + flex-shrink: 0; +} + +.model-back-button svg { + transform: rotate(90deg); +} + +/* Effort drill-in panel: vertical list (not inline footer pills). */ +.model-effort-list { + display: grid; + gap: 2px; + padding: 2px 4px; +} + +.model-effort-list .model-effort-option { + display: flex; align-items: center; - gap: 6px; - padding: 4px 8px; - border: 0; - border-radius: 4px; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: var(--radius); background: transparent; - color: var(--text-muted); - font-size: 12px; - cursor: pointer; + color: var(--text); + font: inherit; + font-size: 12.5px; text-align: left; - transition: color 90ms ease, background 90ms ease; + cursor: pointer; + transition: background 90ms ease; } -.model-effort-option:hover, -.model-effort-footer-levels button:hover { - color: var(--text); - background: var(--bg-soft); +.model-effort-list .model-effort-option:hover { + background: color-mix(in srgb, var(--accent) 10%, transparent); } -.model-effort-option.selected, -.model-effort-footer-levels button.selected { +.model-effort-list .model-effort-option.selected { + background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-strong); - font-weight: 600; -} - -.model-effort-option svg, -.model-effort-footer-levels button svg { - flex-shrink: 0; + font-weight: 660; } -/* "Usar padrão" pill — visually distinguished from concrete levels via a - subtle border so users can tell it's the "no override" affordance, not - just another effort tier. */ -.model-effort-default { - border: 1px solid var(--border); - padding: 3px 7px; -} -.model-effort-default.selected { - border-color: color-mix(in srgb, var(--accent) 44%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, transparent); +.model-effort-list .model-effort-default.selected { + border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); } .subagent-indicator-wrap { From 286c92ad00c3da3a99dbe78ffe79006f4c7e6d45 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 13 Jul 2026 15:19:10 -0300 Subject: [PATCH 003/440] feat(sidebar): left-edge hover rail with peek and pin Add transient peek from a thin left rail; pin to persist expanded; hide desktop topbar reopen in favor of the rail (touch keeps button). --- src/renderer/App.tsx | 106 +++++++++++++++++++++---- src/renderer/components/AppSidebar.tsx | 40 +++++++--- src/renderer/components/TopBar.tsx | 9 ++- src/renderer/i18n.tsx | 2 + src/renderer/styles/layout.css | 97 ++++++++++++++++++++++ 5 files changed, 226 insertions(+), 28 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9b716b5e..28e4bf32 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -232,6 +232,13 @@ type QueuedFollowUp = { type PermissionDecision = 'allow' | 'deny' | 'always' type SidebarMode = 'expanded' | 'compact' | 'hidden' +// Transient peek state — when sidebarMode === 'hidden', hovering the rail +// expands the sidebar visually WITHOUT persisting as 'expanded'. Mouse leave +// (with a small delay to avoid flicker) returns to 'hidden'. Pin button or any +// persistent toggle sets sidebarMode='expanded' and clears peek. +// Touch devices keep the explicit topbar button (hover is unreliable). +const SIDEBAR_PEEK_LEAVE_DELAY_MS = 200 + function isUsableWorkspaceDirectory(path?: string): path is string { const trimmed = path?.trim() return Boolean(trimmed && trimmed !== '/' && trimmed !== '.') @@ -341,6 +348,11 @@ export function App() { const [goal, setGoal] = useState() const [imageReadingTurnId, setImageReadingTurnId] = useState() const [sidebarMode, setSidebarMode] = useState(initialSidebarPreference.current.mode) + // Transient peek: only meaningful when sidebarMode === 'hidden'. The rail + // hit-area (rendered in App) calls setSidebarPeek(true) on hover/focus; + // a leave timer clears it. Pin button persists expanded and clears peek. + const [sidebarPeek, setSidebarPeek] = useState(false) + const peekLeaveTimer = useRef(undefined) const [sidebarWidth, setSidebarWidth] = useState(initialSidebarPreference.current.width) const [reviewMetadata, setReviewMetadata] = useState() const [branchInfo, setBranchInfo] = useState() @@ -450,9 +462,12 @@ export function App() { ? pendingPermissionPrompt : undefined const shouldShowLogin = !noticeAccepted || !entryUnlocked - const effectiveSidebarWidth = sidebarMode === 'hidden' + // When peeking (hidden + hover), the sidebar column expands visually to + // the user's last expanded width — but the persisted mode stays 'hidden'. + const sidebarVisualMode = sidebarMode === 'hidden' && sidebarPeek ? 'expanded' : sidebarMode + const effectiveSidebarWidth = sidebarVisualMode === 'hidden' ? 0 - : sidebarMode === 'compact' + : sidebarVisualMode === 'compact' ? SIDEBAR_COMPACT_WIDTH : sidebarWidth const workingSubagents = useMemo(() => activeSubagents.filter(isActiveSubagentWorking), [activeSubagents]) @@ -562,6 +577,14 @@ export function App() { saveSidebarPreference({ mode: sidebarMode, width: sidebarWidth }) }, [sidebarMode, sidebarWidth]) + // Clear any pending peek-leave timer on unmount so it can't fire after the + // component is gone (would be a no-op setState, but cleaner). + useEffect(() => { + return () => { + if (peekLeaveTimer.current !== undefined) window.clearTimeout(peekLeaveTimer.current) + } + }, []) + useEffect(() => { function handleSidebarShortcut(event: KeyboardEvent) { if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'b') return @@ -858,10 +881,41 @@ export function App() { function toggleSidebarVisibility() { setSidebarMode(current => current === 'hidden' ? 'expanded' : 'hidden') + setSidebarPeek(false) } function toggleSidebarCompact() { setSidebarMode(current => current === 'compact' ? 'expanded' : 'compact') + setSidebarPeek(false) + } + + // Rail hover/focus → peek open. Clears any pending leave timer so the + // sidebar doesn't flicker closed while the pointer re-enters. + function showSidebarPeek() { + if (sidebarMode !== 'hidden') return + if (peekLeaveTimer.current !== undefined) { + window.clearTimeout(peekLeaveTimer.current) + peekLeaveTimer.current = undefined + } + setSidebarPeek(true) + } + + // Rail/sidebar leave → schedule peek close after a short delay. The delay + // tolerates the pointer crossing the gap between rail and sidebar. + function scheduleHideSidebarPeek() { + if (sidebarMode !== 'hidden') return + if (peekLeaveTimer.current !== undefined) window.clearTimeout(peekLeaveTimer.current) + peekLeaveTimer.current = window.setTimeout(() => { + setSidebarPeek(false) + peekLeaveTimer.current = undefined + }, SIDEBAR_PEEK_LEAVE_DELAY_MS) + } + + // Pin = persist expanded. Clears peek so the visual state transitions + // cleanly to the persisted expanded mode. + function pinSidebar() { + setSidebarMode('expanded') + setSidebarPeek(false) } function startSidebarResize(event: ReactPointerEvent) { @@ -4010,10 +4064,30 @@ export function App() { />
- {sidebarMode !== 'hidden' && ( - <> + {sidebarMode === 'hidden' && !sidebarPeek && ( + // Rail: thin hit-area on the left edge. Hover/focus expands the + // sidebar transiently (peek) without persisting. Tab-focusable so + // keyboard users can open it without a pointer. + - + {peek ? ( + // Pin button: persists expanded mode (clears peek). Only shown + // while peeking — in normal expanded mode the collapse button + // is the single control. + + ) : ( + + )}
-
- +
+ + +
{profile.status !== 'ready' && ( diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index b7fac0ae..3ce1ebf6 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -471,6 +471,26 @@ opacity: 1; } +/* Fullscreen views (Profile / Settings): sidebar + rail are not rendered, so + the workspace takes the full grid width. The sidebar column collapses to 0 + via --sidebar-width=0 (set inline by App.tsx when activeView is fullscreen), + but we also neutralize the dim overlay and any residual sidebar chrome so + the view is truly full-bleed. */ +.app-layout.view-fullscreen { + grid-template-columns: 0 minmax(0, 1fr) var(--subagents-panel-width, 0px) var(--review-width) var(--terminal-width); +} + +.app-layout.view-fullscreen .workspace { + grid-column: 1 / -1; +} + +/* No sidebar to dim when fullscreen — neutralize the settings-open dim rules. */ +.app-layout.view-fullscreen.settings-open .sidebar-scroll, +.app-layout.view-fullscreen.settings-open .sidebar-account-wrap { + filter: none; + opacity: 1; +} + .sidebar-scroll { flex: 1 1 auto; min-height: 0; From ab0dfca651e631b5189d5cdf8fca2569edd6625b Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 13 Jul 2026 15:42:44 -0300 Subject: [PATCH 005/440] chore(ui): denser chrome and quieter branding Remove topbar mascot, compact sidebar account/brand, slightly tighter composer pills. Transcript untouched. --- src/renderer/components/AppSidebar.tsx | 12 ++--- src/renderer/components/TopBar.tsx | 4 +- src/renderer/styles/composer.css | 7 +-- src/renderer/styles/layout.css | 63 ++++++++++++++++---------- 4 files changed, 50 insertions(+), 36 deletions(-) diff --git a/src/renderer/components/AppSidebar.tsx b/src/renderer/components/AppSidebar.tsx index 0e400207..566cabae 100644 --- a/src/renderer/components/AppSidebar.tsx +++ b/src/renderer/components/AppSidebar.tsx @@ -373,12 +373,6 @@ export function AppSidebar({ onClick={() => setProfileMenuOpen(open => !open)} aria-expanded={profileMenuOpen} > - - - Verboo:code - {`v${packageJson.version}`} - - {t('sidebar.devBuild')} @@ -386,6 +380,12 @@ export function AppSidebar({ {profile.plan?.name ?? (cliAuth.loggedIn ? t('sidebar.cliConnected') : profile.status === 'unauthenticated' ? t('sidebar.noApiKey') : t('sidebar.planUnavailable'))} + + + Verboo:code + {`v${packageJson.version}`} + {t('sidebar.devBuild')} + diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index 7065dfc7..7f7bbef0 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -1,6 +1,5 @@ import { FileSearch, PanelLeftOpen, Terminal as TerminalIcon } from 'lucide-react' import { SlotText } from 'slot-text/react' -import mascotUrl from '../../../assets/branding/verboo-mascot.png' import { useI18n } from '../i18n' type TopBarProps = { @@ -52,12 +51,13 @@ export function TopBar({ )} + {/* Status text only — the mascot lives in the sidebar footer near the + user account, so the topbar stays a quiet drag region. */}
- Verboo diff --git a/src/renderer/styles/composer.css b/src/renderer/styles/composer.css index d7d835d4..14f3b793 100644 --- a/src/renderer/styles/composer.css +++ b/src/renderer/styles/composer.css @@ -491,16 +491,17 @@ display: inline-flex; align-items: center; justify-content: center; - gap: 7px; + gap: 6px; min-width: 0; - height: 32px; + height: 30px; max-width: 260px; - padding: 0 10px; + padding: 0 9px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--text-muted); cursor: pointer; + font-size: 12px; line-height: 1; white-space: nowrap; } diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index 3ce1ebf6..1974809b 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -4,9 +4,9 @@ display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 9px; + gap: 8px; min-height: var(--titlebar-height); - padding: 0 18px 0 var(--mac-traffic-width); + padding: 0 14px 0 var(--mac-traffic-width); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(18px); @@ -17,14 +17,14 @@ .brand { display: inline-flex; align-items: center; - gap: 9px; + gap: 8px; min-width: 0; } .brand-mark, .topbar-mark { - width: 28px; - height: 28px; + width: 22px; + height: 22px; object-fit: contain; } @@ -35,16 +35,17 @@ .topbar-brand-status { display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; min-width: 0; } .topbar-status-text { color: var(--text-dim); - font-size: 12px; - font-weight: 720; + font-size: 11.5px; + font-weight: 680; letter-spacing: 0; text-transform: lowercase; + opacity: 0.82; } /* Let mousedown on the branding fall through to the `data-tauri-drag-region` @@ -864,8 +865,8 @@ .sidebar-account-wrap { position: relative; flex: 0 0 auto; - margin-top: 12px; - padding-top: 12px; + margin-top: 8px; + padding-top: 8px; border-top: 1px solid var(--border); transition: filter 180ms ease-out, @@ -875,9 +876,9 @@ .sidebar-account { display: grid; grid-template-columns: minmax(0, 1fr); - gap: 10px; + gap: 6px; width: 100%; - padding: 10px 9px; + padding: 8px 8px; border: 0; border-radius: var(--radius); background: transparent; @@ -917,48 +918,60 @@ min-width: 0; } +/* Brand row: compact 1-line — mascot + name + version + disclaimer inline. + Hierarchy: account-profile (user) sits above, brand is a quiet footer. */ .account-brand { - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; - gap: 8px; + gap: 6px; } .account-brand img { - width: 22px; - height: 22px; + width: 16px; + height: 16px; object-fit: contain; + opacity: 0.72; } .account-brand strong { overflow: hidden; - color: var(--text); - font-size: 13px; - font-weight: 760; + color: var(--text-dim); + font-size: 11.5px; + font-weight: 680; text-overflow: ellipsis; white-space: nowrap; } .account-brand strong span { color: var(--accent-strong); + opacity: 0.78; } .account-brand small { color: var(--text-dim); - font-size: 11px; - font-weight: 720; + font-size: 10.5px; + font-weight: 620; + opacity: 0.7; } .account-disclaimer { color: var(--amber) !important; - font-size: 11px; - line-height: 1.25; - white-space: normal !important; + font-size: 10px; + font-weight: 620; + line-height: 1.2; + white-space: nowrap !important; + opacity: 0.72; } .account-profile { grid-template-columns: auto minmax(0, 1fr); align-items: center; - gap: 10px; + gap: 8px; +} + +.account-profile .account-avatar { + width: 26px; + height: 26px; } .account-avatar { From 78dda4465373aebad3dd849fd5160e013a5a3d73 Mon Sep 17 00:00:00 2001 From: Gabriel Grasel Moura Date: Mon, 13 Jul 2026 16:01:06 -0300 Subject: [PATCH 006/440] fix(composer): remove context meter from toolbar Ivo feedback: drop the context usage control from the composer chrome. --- src/renderer/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d6be72dd..38cba134 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -69,7 +69,6 @@ import { SkillApprovalPanel } from './features/skills/SkillApprovalPanel' import type { ExtractionStatus, ModelReasoning, VisionFallbackConsent, VisionFallbackState } from '../shared/types' import { recognizeImage } from './features/ocr/ocrService' import { Composer } from './features/composer/Composer' -import { ContextMeter } from './features/context/ContextMeter' import { estimateTotalContextTokens } from './features/context/ContextPanel' import { TokenRateMeter } from './features/context/TokenRateMeter' import { FeedbackDialog } from './features/feedback/FeedbackDialog' @@ -4419,7 +4418,6 @@ export function App() { rightToolbar={ <> - Date: Mon, 13 Jul 2026 18:14:22 -0300 Subject: [PATCH 007/440] fix(ui): compact composer menus and quiet settings chrome Shrink access/model popovers with full description wrap; remove topbar ready status; drop settings nav purple left bar. --- src/renderer/App.tsx | 1 - src/renderer/components/TopBar.tsx | 16 +-- .../features/access/AccessSelector.tsx | 4 +- src/renderer/styles/composer.css | 107 +++++++++++------- src/renderer/styles/surfaces.css | 9 +- 5 files changed, 71 insertions(+), 66 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 38cba134..d49c2d3e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4057,7 +4057,6 @@ export function App() {
void terminalOpen: boolean terminalUnavailableReason?: string @@ -16,7 +14,6 @@ type TopBarProps = { export function TopBar({ sidebarVisible, - statusLabel, onToggleSidebar, terminalOpen, terminalUnavailableReason, @@ -51,17 +48,8 @@ export function TopBar({ )} - {/* Status text only — the mascot lives in the sidebar footer near the - user account, so the topbar stays a quiet drag region. */} -
- - - -
+ {/* Quiet drag spacer — no "ready/pronto" status near traffic lights. */} +