diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c555016e13..4ccb452de1 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3112,7 +3112,14 @@ pub fn run() { // `setup()` returns, which is why clamping here alone is // not enough. window_state::install_dpi_guard(&window); - if !window_state::restore_main(&window) { + // No saved geometry (first launch, or the save is stale / + // belongs to a detached monitor) → open filling the work area + // rather than the modest default size from `tauri.conf.json`. + // `center_main` stays as the fallback for the case where no + // monitor resolves at all. + if !window_state::restore_main(&window) + && !window_state::maximize_to_work_area(&window) + { window_state::center_main(&window); } if !daemon_mode { diff --git a/app/src-tauri/src/window_state.rs b/app/src-tauri/src/window_state.rs index 9470c2de43..d2bd540f3b 100644 --- a/app/src-tauri/src/window_state.rs +++ b/app/src-tauri/src/window_state.rs @@ -159,9 +159,14 @@ pub fn save_main(window: &WebviewWindow) { /// Returns `true` when saved geometry was applied. Returns `false` when /// no saved file exists, the file is malformed, or the saved position /// falls outside every currently-attached monitor's work area (e.g. the -/// user undocked an external display); the caller is then expected to -/// fall back to a centered default so we never strand the window -/// off-screen. +/// user undocked an external display). +/// +/// The caller is then expected to fall back to a placement that cannot +/// strand the window off-screen: [`maximize_to_work_area`] first, and +/// [`center_main`] if even that resolves no monitor. Note this is no +/// longer "the centered default" — a `false` here means there is no +/// usable saved geometry, and the product default for that is a window +/// filling the work area. /// /// Even when the saved monitor is still attached, the restored size is /// clamped to that monitor's work area (issue #2282) so a window saved @@ -178,7 +183,7 @@ pub fn restore_main(window: &WebviewWindow) -> bool { Ok(s) => s, Err(err) => { log::warn!( - "[window-state] parse {} failed: {err}; using default placement", + "[window-state] parse {} failed: {err}; falling back to default placement", path.display() ); return false; @@ -197,7 +202,7 @@ pub fn restore_main(window: &WebviewWindow) -> bool { pick_monitor_for_window(state.x, state.y, state.width, state.height, &work_areas) else { log::info!( - "[window-state] saved geometry x={} y={} w={} h={} not on any attached monitor's work area; falling back to centered default", + "[window-state] saved geometry x={} y={} w={} h={} not on any attached monitor's work area; falling back to default placement", state.x, state.y, state.width, @@ -246,6 +251,66 @@ pub fn restore_main(window: &WebviewWindow) -> bool { true } +/// Geometry for a window that should fill `monitor`'s work area. +/// +/// Goes through [`clamp_to_work_area`] rather than returning the raw work-area +/// dimensions, because [`clamp_size`] enforces the `MIN_WINDOW_*` floor: a work +/// area smaller than 480x360 would otherwise produce a window below the +/// module's stated minimum, and make first launch behave differently from +/// [`restore_main`] and [`center_main`]. +/// +/// Split out from [`maximize_to_work_area`] so the invariant is testable +/// without a live window handle. +fn work_area_fill_geometry(monitor: WorkArea) -> (i32, i32, u32, u32) { + clamp_to_work_area(monitor.x, monitor.y, monitor.width, monitor.height, monitor) +} + +/// Fill the target monitor's **work area** on a first launch (no saved +/// geometry), so the app opens at full usable size instead of the modest +/// default declared in `tauri.conf.json`. +/// +/// Deliberately the work area, not the full monitor bounds: the macOS menu +/// bar / Dock and the Windows taskbar are excluded, so the window is +/// "maximized" in the sense a user means it, without covering OS chrome or +/// tripping the clamping in [`clamp_to_work_area`]. +/// +/// Position is applied before size for the same DPI reason documented on +/// [`restore_main`] — the size that sticks is the one measured against the +/// monitor the window has actually arrived on. +/// +/// Returns `false` when no monitor can be resolved, so the caller can fall +/// back to [`center_main`]. +pub fn maximize_to_work_area(window: &WebviewWindow) -> bool { + let work_areas = collect_work_areas(window); + let Some(monitor) = + primary_or_current_work_area(window).or_else(|| work_areas.first().copied()) + else { + log::warn!("[window-state] no monitor resolved; cannot size to work area"); + return false; + }; + + let (x, y, width, height) = work_area_fill_geometry(monitor); + + if let Err(err) = window.set_position(PhysicalPosition::new(x, y)) { + log::warn!("[window-state] work-area set_position failed: {err}"); + return false; + } + if let Err(err) = window.set_size(PhysicalSize::new(width, height)) { + log::warn!("[window-state] work-area set_size failed: {err}"); + return false; + } + log::info!( + "[window-state] no saved geometry; opened filling work area x={} y={} w={} h={} (work area {}x{})", + x, + y, + width, + height, + monitor.width, + monitor.height + ); + true +} + /// Center the main window on the primary display (or its current monitor /// if `current_monitor` resolves) when no saved state applied. /// @@ -815,6 +880,31 @@ mod tests { assert_eq!(y, 100); } + #[test] + fn work_area_fill_uses_the_whole_work_area_on_a_normal_monitor() { + let (x, y, w, h) = work_area_fill_geometry(wa(0, 60, 3600, 2190)); + assert_eq!((x, y), (0, 60)); + assert_eq!((w, h), (3600, 2190)); + } + + #[test] + fn work_area_fill_keeps_the_offset_of_a_secondary_monitor() { + // A monitor to the left of the primary has a negative origin; filling + // its work area must land there, not at (0, 0). + let (x, y, w, h) = work_area_fill_geometry(wa(-1920, 0, 1920, 1080)); + assert_eq!((x, y), (-1920, 0)); + assert_eq!((w, h), (1920, 1080)); + } + + #[test] + fn work_area_fill_never_goes_below_the_minimum_window_size() { + // The invariant this helper exists for: a work area smaller than + // MIN_WINDOW_* must still produce at least the minimum, matching what + // `restore_main` and `center_main` guarantee. + let (_, _, w, h) = work_area_fill_geometry(wa(0, 0, 320, 200)); + assert_eq!((w, h), (MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)); + } + #[test] fn clamp_size_only_caps_to_work_area() { let work_area = wa(0, 0, 1024, 600); diff --git a/app/src/App.tsx b/app/src/App.tsx index 7ae765d7f8..4f582229db 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -260,6 +260,13 @@ export function AppShellDesktop() { {chromeless ? ( content ) : ( + // Nothing sets `unframed` today. It existed for live CEF provider + // webviews — WebviewHost handed the Rust side a plain rectangle and + // CEF composited that child view above the whole HTML layer, so a + // rounded card under it showed four square corners punching through + // the radius. That surface was removed upstream along with + // WebviewHost, so no route needs the escape hatch right now; the + // prop stays on the primitive for the next full-bleed surface. }>{content} )} diff --git a/app/src/components/chat/ChatNewWindowHero.tsx b/app/src/components/chat/ChatNewWindowHero.tsx index c18409ff68..fd281fe559 100644 --- a/app/src/components/chat/ChatNewWindowHero.tsx +++ b/app/src/components/chat/ChatNewWindowHero.tsx @@ -147,7 +147,12 @@ export default function ChatNewWindowHero() { the app background. */}
+ // `surface-muted`, not `surface/80`: the translucent fill only read as a + // card while the chat page painted a darker tint beneath it. The page is + // the card surface now, so surface/80 over surface would flatten to the + // same colour and leave only the border. This is the same lift token the + // message bubbles use. + className="animate-fade-up rounded-2xl border border-line/80 bg-surface-muted p-6 shadow-soft dark:border-line/80"> {/* Animated greeting */}

{typedWelcome} diff --git a/app/src/components/layout/TwoPaneNav.tsx b/app/src/components/layout/TwoPaneNav.tsx index 083c2d651f..2472c903fe 100644 --- a/app/src/components/layout/TwoPaneNav.tsx +++ b/app/src/components/layout/TwoPaneNav.tsx @@ -66,14 +66,18 @@ export default function TwoPaneNav({ data-testid={`two-pane-nav-${item.value}`} aria-current={active ? 'page' : undefined} onClick={() => onSelect(item.value)} - className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors ${ + // Same row spec as SidebarNav / SettingsSidebar / + // ThreadList: 15px, medium by default and semibold when + // selected, with an alpha fill that lifts against both the + // translucent chrome and an opaque pane. + className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[14px] transition-colors ${ active - ? 'bg-surface-subtle font-medium text-content' - : 'text-content-secondary hover:bg-surface-hover hover:text-content' + ? 'bg-surface/70 font-semibold text-content' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> {item.icon ?? null} diff --git a/app/src/components/layout/shell/AppSidebar.tsx b/app/src/components/layout/shell/AppSidebar.tsx index c785f38d22..e861791de9 100644 --- a/app/src/components/layout/shell/AppSidebar.tsx +++ b/app/src/components/layout/shell/AppSidebar.tsx @@ -46,12 +46,12 @@ function FooterNavButton({ onClick={onClick} title={label} aria-current={active ? 'page' : undefined} - className={`group flex flex-shrink-0 items-center justify-center gap-2 border-t border-line/70 px-3 py-1 text-[11px] transition-colors cursor-pointer dark:border-line/70 ${ + className={`group mx-2 flex flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors cursor-pointer ${ active - ? 'bg-surface text-content font-medium' - : 'text-content-muted hover:bg-surface-strong/70 hover:text-content-secondary dark:hover:bg-surface-muted/60' + ? 'bg-surface/70 text-content font-semibold' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> - + {label} ); @@ -67,6 +67,8 @@ function FooterNavButton({ * │ SidebarSlot │ dynamic, per-route content (scrolls) * │ (Outlet) │ * ├──────────────┤ + * │ Rewards/Fdbk │ account affordances + * ├──────────────┤ * │ beta footer │ app-wide build/version line * └──────────────┘ * @@ -122,14 +124,24 @@ export default function AppSidebar() { }; return ( -
-
+ // Sits directly on the window chrome with no fill of its own, so the + // sidebar and the frame around the content card are one continuous surface. + // The legibility scrim lives on the shell root ({@link RootShellLayout}) and + // deliberately NOT here — scrimming only this column would tint it + // differently from the chrome beside the card, which is the seam the + // two-layer look exists to remove. Regions below are separated by spacing + // alone; the hairline seams the old opaque panel needed would draw lines + // across the chrome. +
+
-
+
+ {/* Flex column so routes that project more than one region can order + them via Tailwind `order-*`. */}
{/* Slim account affordances pinned above the status bar — Rewards then @@ -152,7 +164,7 @@ export default function AppSidebar() { /> {/* App-wide footer: connectivity status + build/version, pinned to the bottom of the sidebar. */} -
+
· diff --git a/app/src/components/layout/shell/CollapsedNavRail.tsx b/app/src/components/layout/shell/CollapsedNavRail.tsx index 2f045f8ee3..fc86d9ab85 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.tsx @@ -65,8 +65,8 @@ export default function CollapsedNavRail() { aria-current={homeActive ? 'page' : undefined} className={`${RAIL_BTN} ${ homeActive - ? 'bg-surface text-content shadow-sm' - : 'text-content-muted hover:bg-surface-hover hover:text-content-secondary' + ? 'bg-surface/70 text-content' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> @@ -99,8 +99,8 @@ export default function CollapsedNavRail() { aria-current={active ? 'page' : undefined} className={`${RAIL_BTN} ${ active - ? 'bg-surface text-content shadow-sm' - : 'text-content-muted hover:bg-surface-hover hover:text-content-secondary' + ? 'bg-surface/70 text-content' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> {showBadge && ( @@ -124,8 +124,8 @@ export default function CollapsedNavRail() { data-analytics-id="collapsed-rail-settings" className={`${RAIL_BTN} ${ settingsActive - ? 'bg-surface text-content shadow-sm' - : 'text-content-muted hover:bg-surface-hover hover:text-content-secondary' + ? 'bg-surface/70 text-content' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> diff --git a/app/src/components/layout/shell/ContentSurface.test.tsx b/app/src/components/layout/shell/ContentSurface.test.tsx new file mode 100644 index 0000000000..cb77c624aa --- /dev/null +++ b/app/src/components/layout/shell/ContentSurface.test.tsx @@ -0,0 +1,49 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import ContentSurface from './ContentSurface'; + +describe('ContentSurface', () => { + afterEach(cleanup); + + it('renders children', () => { + render( + +

routed page

+
+ ); + expect(screen.getByText('routed page')).toBeTruthy(); + }); + + it('frames the surface as an inset rounded card by default', () => { + render(body); + const surface = screen.getByTestId('app-content-surface'); + expect(surface.className).toContain('rounded-2xl'); + expect(surface.className).toContain('shadow-content-edge'); + // Even inset on all four sides so the chrome frames the card. + expect(surface.className).toContain('m-3'); + expect(surface.dataset.unframed).toBeUndefined(); + }); + + it('drops the radius, insets and seam when unframed', () => { + render(body); + const surface = screen.getByTestId('app-content-surface'); + // The compositing constraint this exists for: content drawn above the HTML + // layer as a plain rectangle would leave four square corners poking through + // a rounded card. + expect(surface.className).not.toContain('rounded-2xl'); + expect(surface.className).not.toContain('shadow-content-edge'); + expect(surface.className).not.toContain('m-3'); + expect(surface.dataset.unframed).toBe('true'); + }); + + it('keeps the bounded flex column in both modes so the page owns the scroll', () => { + const { rerender } = render(body); + expect(screen.getByTestId('app-content-surface').className).toContain('min-h-0'); + rerender(body); + const surface = screen.getByTestId('app-content-surface'); + expect(surface.className).toContain('min-h-0'); + expect(surface.className).toContain('flex-1'); + expect(surface.className).toContain('bg-surface'); + }); +}); diff --git a/app/src/components/layout/shell/ContentSurface.tsx b/app/src/components/layout/shell/ContentSurface.tsx new file mode 100644 index 0000000000..b6fd9c3a1f --- /dev/null +++ b/app/src/components/layout/shell/ContentSurface.tsx @@ -0,0 +1,55 @@ +import debugFactory from 'debug'; +import type { ReactNode } from 'react'; + +const log = debugFactory('shell:content-surface'); + +/** Shared flex/overflow behaviour — identical in both framed and unframed modes. */ +const BASE = 'relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden bg-surface'; + +/** + * Inset, rounded card floating on the window chrome. Even 12px margin on all + * four sides so the chrome reads as a deliberate frame rather than a seam. The + * top gap lands larger than 12px in practice because the window-drag band + * ({@link WindowDragBar}) sits above the card inside the same column. + */ +const FRAMED = `${BASE} m-3 rounded-2xl shadow-content-edge`; + +interface ContentSurfaceProps { + children: ReactNode; + /** + * Render edge-to-edge with square corners and no card seam. + * + * Written for native CEF provider webviews: `WebviewHost` handed the Rust + * side a plain `{x, y, width, height}` rectangle and CEF composited that + * child view *above* the whole HTML layer, so its corners could not be masked + * by `overflow-hidden`, a CSS radius, or any HTML overlay — a framed card + * under a live webview showed four square corners punching through. + * + * That surface was removed upstream (`WebviewHost` is gone), so **nothing + * sets this today**. Kept because the constraint recurs for any content the + * compositor draws above the HTML layer, and because a full-bleed page is a + * reasonable thing to want from a layout primitive. + */ + unframed?: boolean; +} + +/** + * The app's single content surface: the opaque sheet that routed pages render + * onto, floating on the themed chrome layer ({@link AppBackground}) that the + * sidebar also sits on. + * + * This is the "card" half of the two-layer shell. The chrome carries the + * theme's hue; this surface stays neutral so page content reads against a + * consistent background across every theme. + */ +export default function ContentSurface({ children, unframed = false }: ContentSurfaceProps) { + log('render: unframed=%s', unframed); + return ( +
+ {children} +
+ ); +} diff --git a/app/src/components/layout/shell/RootShellLayout.test.tsx b/app/src/components/layout/shell/RootShellLayout.test.tsx new file mode 100644 index 0000000000..74a6074def --- /dev/null +++ b/app/src/components/layout/shell/RootShellLayout.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import RootShellLayout from './RootShellLayout'; + +// Render i18n keys verbatim so assertions don't depend on locale copy. +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +// The collapsed rail pulls in routing + nav config; the shell's own geometry is +// the unit under test. +vi.mock('./CollapsedNavRail', () => ({ default: () => null })); +// macOS/Tauri-gated, and covered by its own spec. +vi.mock('./WindowDragBar', () => ({ default: () => null })); + +function renderShell(props: { unframed?: boolean } = {}) { + return renderWithProviders( + sidebar body} {...props}> +
routed page
+
+ ); +} + +describe('RootShellLayout', () => { + it('renders the sidebar and the routed content', () => { + renderShell(); + expect(screen.getByText('sidebar body')).toBeTruthy(); + expect(screen.getByText('routed page')).toBeTruthy(); + }); + + it('mounts the routed content inside the content surface', () => { + renderShell(); + const surface = screen.getByTestId('app-content-surface'); + expect(surface.contains(screen.getByText('routed page'))).toBe(true); + }); + + it('frames the content surface as a card by default', () => { + renderShell(); + expect(screen.getByTestId('app-content-surface').dataset.unframed).toBeUndefined(); + }); + + it('forwards unframed so a live CEF webview gets a square, edge-to-edge pane', () => { + renderShell({ unframed: true }); + expect(screen.getByTestId('app-content-surface').dataset.unframed).toBe('true'); + }); + + it('leaves the resize divider unfilled so the chrome reads as one surface', () => { + renderShell(); + const divider = screen.getByTestId('root-shell-divider'); + expect(divider.className).toContain('bg-transparent'); + expect(divider.className).not.toContain('bg-surface-strong'); + }); + + it('exposes the divider as a keyboard-operable separator', () => { + renderShell(); + const divider = screen.getByTestId('root-shell-divider'); + expect(divider.getAttribute('role')).toBe('separator'); + expect(divider.getAttribute('aria-orientation')).toBe('vertical'); + expect(divider.tabIndex).toBe(0); + }); +}); diff --git a/app/src/components/layout/shell/RootShellLayout.tsx b/app/src/components/layout/shell/RootShellLayout.tsx index 978720f748..46356957a2 100644 --- a/app/src/components/layout/shell/RootShellLayout.tsx +++ b/app/src/components/layout/shell/RootShellLayout.tsx @@ -11,6 +11,7 @@ import { } from '../../../store/layoutSlice'; import { Tooltip } from '../../ui'; import CollapsedNavRail from './CollapsedNavRail'; +import ContentSurface from './ContentSurface'; import WindowDragBar from './WindowDragBar'; // `app-shell` (not the older `root-shell`) so the persisted geometry seeds @@ -55,17 +56,29 @@ interface RootShellLayoutProps { sidebar: ReactNode; /** Dynamic main content (the routed page area). */ children: ReactNode; + /** + * Render the content edge-to-edge instead of as an inset card. Forwarded to + * {@link ContentSurface} — see its docs for the compositing constraint this + * exists for, and why no route sets it today. + */ + unframed?: boolean; } /** - * Full-bleed, viewport-filling two-pane shell for the app root: a resizable - * sidebar on the left and the main content on the right, separated by a flush - * hairline seam. Unlike the in-page {@link TwoPanelLayout}, this fills its - * container edge-to-edge (no card, no rounded corners) because it *is* the - * window chrome. The dragged width persists per user via the `layout` slice - * (id `root-shell`); the sidebar is always shown. + * Viewport-filling two-pane shell for the app root, built as two layers rather + * than two opaque panes: + * + * - **Chrome** — this component paints nothing of its own. The themed + * {@link AppBackground} behind it shows through here and behind the + * sidebar, so the frame carries the theme's hue as one continuous surface. + * - **Card** — the routed content sits on a single inset, rounded + * {@link ContentSurface}, the only opaque sheet in the shell. + * + * The two separate by fill contrast, which is why the sidebar needs no border + * and the panes need no divider fill. The dragged sidebar width persists per + * user via the `layout` slice (id `app-shell`). */ -export default function RootShellLayout({ sidebar, children }: RootShellLayoutProps) { +export default function RootShellLayout({ sidebar, children, unframed }: RootShellLayoutProps) { const { t } = useT(); const dispatch = useAppDispatch(); const layout = useAppSelector(selectPanelLayout(LAYOUT_ID, LAYOUT_DEFAULTS)); @@ -146,7 +159,19 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr ); return ( -
+ // The chrome layer. One legibility scrim across the WHOLE shell — the + // sidebar column and the frame around the content card — so the two read as + // a single continuous surface. Scrimming per-pane would tint them + // differently and reintroduce the very seam this layout removes. + // + // The alpha is deliberately light: the themed AppBackground behind it is an + // *animated* WebGL mesh gradient, and the content card above is opaque, so + // the chrome is the only place that motion is visible at all. A heavier + // scrim (or a backdrop blur, which also smears the 18px dotted canvas) + // flattens it back into paint and leaves the shader burning GPU for nothing. + // /30 is the legibility knob — raise it if sidebar labels wash out, which is + // most likely under a `backdrop: image` theme rather than the mesh. +
{isOpen && ( <>
+ // Transparent at rest: the sidebar and the content card separate by + // fill contrast, so a filled seam would draw a line across the + // chrome that the two-layer look is trying to remove. It still + // lights up on hover/focus to advertise the drag affordance. + className="group relative w-px flex-shrink-0 cursor-col-resize select-none self-stretch bg-transparent focus:outline-none"> - +
)} @@ -181,7 +210,7 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr native CEF webview glued to the content's bounds, which composites above the HTML layer — starts to its right and never covers it. */} {!isOpen && ( -
+
{/* macOS overlay title bar (titleBarStyle: Overlay) floats the traffic lights over the top-left. The expanded SidebarHeader dodges them by right-aligning, but this narrow rail can't — so reserve a draggable @@ -207,20 +236,21 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr {/* Keep the primary nav reachable while collapsed: an icon-only rail. */} -
+
)} -
- {children} - {/* macOS overlay-title-bar drag strip — a transparent overlay pinned on - TOP of the routed view (last child) so full-bleed surfaces (Tiny - Place world, Chat backdrop) stay edge-to-edge while the top of the - window still drags. The sidebar is excluded — its header already - drags in place. No-op off macOS / outside Tauri. */} +
+ {/* macOS overlay-title-bar band, in flow ABOVE the content card so the + traffic lights land on bare chrome instead of on the card. No-op off + macOS / outside Tauri, where the native title bar already owns that + band and reserving one would just waste 28px. */} + {children}
); diff --git a/app/src/components/layout/shell/SidebarHeader.test.tsx b/app/src/components/layout/shell/SidebarHeader.test.tsx index 2d113f91e6..ae3957d93c 100644 --- a/app/src/components/layout/shell/SidebarHeader.test.tsx +++ b/app/src/components/layout/shell/SidebarHeader.test.tsx @@ -6,14 +6,12 @@ import { renderWithProviders } from '../../../test/test-utils'; import SidebarHeader from './SidebarHeader'; const mockNavigate = vi.fn(); -const mockHome = vi.fn(); const mockHide = vi.fn(); vi.mock('react-router-dom', async importOriginal => { const actual = await importOriginal(); return { ...actual, useNavigate: () => mockNavigate }; }); -vi.mock('./useHomeNav', () => ({ useHomeNav: () => mockHome })); vi.mock('./RootShellLayout', () => ({ useRootSidebar: () => ({ hide: mockHide }) })); // Return i18n keys verbatim so queries don't depend on locale. vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); @@ -21,23 +19,15 @@ vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) describe('SidebarHeader', () => { beforeEach(() => vi.clearAllMocks()); - it('renders Home, Keyboard Shortcuts, Settings, and Collapse buttons', () => { + it('renders Keyboard Shortcuts, Settings, and Collapse buttons', () => { renderWithProviders(, { initialEntries: ['/home'] }); - expect(screen.getByRole('button', { name: 'nav.home' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'shortcuts.title' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'nav.settings' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'chat.hideSidebar' })).toBeInTheDocument(); - // The wallet shortcut was removed (replaced by Home, clear of the macOS - // window controls). + // The wallet shortcut was removed long ago; Home followed it, since the + // primary nav directly below already carries Chat. expect(screen.queryByRole('button', { name: 'nav.wallet' })).not.toBeInTheDocument(); - }); - - it('Home button has correct data-analytics-id', () => { - renderWithProviders(, { initialEntries: ['/home'] }); - expect(screen.getByRole('button', { name: 'nav.home' })).toHaveAttribute( - 'data-analytics-id', - 'sidebar-header-home' - ); + expect(screen.queryByRole('button', { name: 'nav.home' })).not.toBeInTheDocument(); }); it('shortcuts button opens the keyboard-shortcuts help directory', () => { @@ -73,12 +63,6 @@ describe('SidebarHeader', () => { }); }); - it('Home button invokes the shared Home action', () => { - renderWithProviders(, { initialEntries: ['/home'] }); - fireEvent.click(screen.getByRole('button', { name: 'nav.home' })); - expect(mockHome).toHaveBeenCalledTimes(1); - }); - it('Collapse button calls hide()', () => { renderWithProviders(, { initialEntries: ['/home'] }); fireEvent.click(screen.getByRole('button', { name: 'chat.hideSidebar' })); diff --git a/app/src/components/layout/shell/SidebarHeader.tsx b/app/src/components/layout/shell/SidebarHeader.tsx index a367b0399e..37041ca300 100644 --- a/app/src/components/layout/shell/SidebarHeader.tsx +++ b/app/src/components/layout/shell/SidebarHeader.tsx @@ -5,48 +5,26 @@ import { useT } from '../../../lib/i18n/I18nContext'; import { settingsNavState } from '../../settings/modal/settingsOverlay'; import { Tooltip } from '../../ui'; import { useRootSidebar } from './RootShellLayout'; -import { useHomeNav } from './useHomeNav'; const ICON_BTN = 'flex h-7 w-7 flex-none items-center justify-center rounded-md text-content-muted transition-colors hover:bg-surface-hover hover:text-content-secondary'; /** - * Thin utility header at the top of the root sidebar: jump Home, open keyboard - * shortcuts, open Settings, and collapse the sidebar. Language is chosen from - * Settings, not here. + * Thin utility header at the top of the root sidebar: keyboard shortcuts, + * Settings, and collapse. Language is chosen from Settings, not here. */ export default function SidebarHeader() { const { t } = useT(); const navigate = useNavigate(); const location = useLocation(); const { hide } = useRootSidebar(); - const handleHome = useHomeNav(); return ( // Right-aligned so the macOS traffic lights (top-left, overlay title bar) // sit in the empty left space — the icons stay clear of the window controls // and inline with them (no extra top padding). -
+
- {/* Home shortcut (replaces the former wallet shortcut). */} - - - - {/* Keyboard shortcuts — one-click open of the help directory (also ? / ⌘/). */}
diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 462f9b5939..c8a2859406 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -19,6 +19,7 @@ import SuperContextToggle from '../../components/chat/SuperContextToggle'; import { whenSuperContextWriteSettled } from '../../components/chat/superContextWrite'; import WorkflowProposalCard from '../../components/chat/WorkflowProposalCard'; import { ConfirmationModal } from '../../components/intelligence/ConfirmationModal'; +import PanelHeader from '../../components/layout/PanelHeader'; import { SidebarContent } from '../../components/layout/shell/SidebarSlot'; import { settingsNavState } from '../../components/settings/modal/settingsOverlay'; import UpsellBanner from '../../components/upsell/UpsellBanner'; @@ -301,7 +302,6 @@ const Conversations = ({ // General/Subconscious/Tasks chips were removed. Subconscious reflections and // task/worker threads have dedicated surfaces (Intelligence, Tasks board). const selectedLabel = GENERAL_TAB_VALUE; - const [threadSearch, setThreadSearch] = useState(''); const [sendError, setSendError] = useState(null); // Recorded by the slice for *every* create path (#5156) — including the shell's // "New chat" button and the home-nav shortcut, which have no UI of their own — @@ -1791,14 +1791,6 @@ const Conversations = ({ ); }, [filteredThreads]); - // Free-text search over the thread sidebar — filters the visible list by - // title (mirrors the settings sidebar search). - const visibleThreads = useMemo(() => { - const q = threadSearch.trim().toLowerCase(); - if (!q) return sortedThreads; - return sortedThreads.filter(thread => (thread.title ?? '').toLowerCase().includes(q)); - }, [sortedThreads, threadSearch]); - const isSidebar = variant === 'sidebar'; // "New window" = the merged Home surface: a page-variant chat whose selected // thread has no messages yet. We show the greeting + banners hero above a @@ -1869,10 +1861,8 @@ const Conversations = ({ // mode; the embedded `variant="sidebar"` mode shows no thread list at all. const threadSidebar = ( void handleCreateNewThread()} onSelectThread={id => { dispatch(setSelectedThread(id)); @@ -1923,6 +1913,7 @@ const Conversations = ({ ); // Main chat area (right pane): header, message list, composer. + const showChatHeader = !isSidebar && Boolean(selectedThreadId); const mainPanel = (
+ {/* Page header band — the same flush title band every other page opens + with (PanelHeader / bg-surface-muted), naming the open thread. Page + variant only: the embedded sidebar variant lives in a narrow aside + where a full band would cost more vertical room than it earns, and its + host already titles the surface. Suppressed until a thread resolves so + a brand-new chat doesn't open with an empty band above the hero. */} + {showChatHeader && selectedThreadId && ( + // No fade beneath the band, deliberately. The bottom of the pane needs + // one because the composer is absolutely positioned and floats over the + // messages; this header is `flex-shrink-0` in normal flow, so the scroll + // area simply starts below it and nothing ever scrolls underneath. A + // gradient here has no overlap to soften — it just paints a veil over + // whatever message happens to be at the top of the list. + + )} - {/* Full-width fade so messages dissolve into the background (black/white - per theme) behind the floating composer. Page variant only. */} + {/* Full-width fade so messages dissolve into the page behind the floating + composer. Page variant only. + + Fades to `surface` — the token the content card actually paints — not + a hardcoded white/black pair. Those matched only while the page was a + transparent window onto the app canvas (`--surface-canvas`, pure black + in dark); on the inset card (`--surface`, neutral-900) they fade to a + colour the card never reaches and leave a visible band. The token also + keeps this correct for custom themes, which the literals never were. */} {!isSidebar && (