Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
310f8ee
feat(shell): rebuild the app shell as chrome + an inset content card
graycyrus Aug 7, 2026
57644a2
style(shell): give the content card an even inset on all four sides
graycyrus Aug 7, 2026
459a435
fix(shell): let the animated backdrop show through the chrome again
graycyrus Aug 7, 2026
01dd34f
fix(chat): match the composer fade to the surface it actually sits on
graycyrus Aug 7, 2026
dbedf5e
feat(ui): thin, theme-tinted, auto-hiding scrollbars app-wide
graycyrus Aug 7, 2026
e9520b4
feat(chat): restyle the conversation list as grouped, inset rows
graycyrus Aug 7, 2026
95fd9d0
feat(shell): open up the sidebar and unify its selection style
graycyrus Aug 7, 2026
dc038ba
feat(chat): give chat a title band, and make Brain's reach the card e…
graycyrus Aug 7, 2026
8705ee5
style(nav): put every nav list on one type ramp
graycyrus Aug 7, 2026
57a1475
feat(window): open filling the work area when there is no saved geometry
graycyrus Aug 7, 2026
787e7bd
style(nav): drop the nav type ramp a point
graycyrus Aug 7, 2026
aeb17cc
fix(chat): drop the fade under the chat header
graycyrus Aug 7, 2026
0ba99cf
fix: address CodeRabbit review on #5442
graycyrus Aug 7, 2026
2a97b24
docs(window): correct restore_main's stale "centered default" contract
graycyrus Aug 7, 2026
a20d919
Merge remote-tracking branch 'upstream/main' into feat/two-layer-shel…
graycyrus Aug 7, 2026
a69b210
style(nav): let weight mark selection on its own
graycyrus Aug 7, 2026
35f6203
test(window): pin the work-area fill invariant
graycyrus Aug 13, 2026
13459e7
Merge upstream/main into feat/two-layer-shell-chrome
graycyrus Aug 13, 2026
8eb89ea
fix(shell): unwire `unframed` — the surface it guarded no longer exists
graycyrus Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
window_state::center_main(&window);
}
if !daemon_mode {
Expand Down
100 changes: 95 additions & 5 deletions app/src-tauri/src/window_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,14 @@ pub fn save_main<R: Runtime>(window: &WebviewWindow<R>) {
/// 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
Expand All @@ -178,7 +183,7 @@ pub fn restore_main<R: Runtime>(window: &WebviewWindow<R>) -> 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;
Expand All @@ -197,7 +202,7 @@ pub fn restore_main<R: Runtime>(window: &WebviewWindow<R>) -> 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,
Expand Down Expand Up @@ -246,6 +251,66 @@ pub fn restore_main<R: Runtime>(window: &WebviewWindow<R>) -> 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<R: Runtime>(window: &WebviewWindow<R>) -> 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.
///
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<RootShellLayout sidebar={<AppSidebar />}>{content}</RootShellLayout>
)}
</div>
Expand Down
7 changes: 6 additions & 1 deletion app/src/components/chat/ChatNewWindowHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,12 @@ export default function ChatNewWindowHero() {
the app background. */}
<div

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high security confident

Replace // comments inside the JSX tag with {/ /} or remove them

The diff inserts // line comments directly inside a JSX opening tag, between the data-walkthrough and className attributes. // comments are not valid there; JSX requires {/* */} for comments inside element bodies, and // inside a tag will be parsed as part of an attribute expression and produce a syntax error. This change will break the build.

        <div
          data-walkthrough="home-card"
          // `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">

[RULE] JSX syntax ·

data-walkthrough="home-card"
className="animate-fade-up rounded-2xl border border-line/80 bg-surface/80 p-6 shadow-soft backdrop-blur-sm dark:border-line/80">
// `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 */}
<h1 className="min-h-[3.5rem] text-2xl text-center font-bold text-content">
{typedWelcome}
Expand Down
12 changes: 8 additions & 4 deletions app/src/components/layout/TwoPaneNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}`}>
<span
className={`shrink-0 ${
active ? 'text-primary-600 dark:text-primary-400' : 'text-content-faint'
active ? 'text-content-secondary' : 'text-content-faint'
}`}>
{item.icon ?? null}
</span>
Expand Down
28 changes: 20 additions & 8 deletions app/src/components/layout/shell/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}`}>
<NavIcon id={iconId} className="h-3.5 w-3.5 flex-shrink-0" />
<NavIcon id={iconId} className="h-4 w-4 flex-shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</button>
);
Expand All @@ -67,6 +67,8 @@ function FooterNavButton({
* │ SidebarSlot │ dynamic, per-route content (scrolls)
* │ (Outlet) │
* ├──────────────┤
* │ Rewards/Fdbk │ account affordances
* ├──────────────┤
* │ beta footer │ app-wide build/version line
* └──────────────┘
*
Expand Down Expand Up @@ -122,14 +124,24 @@ export default function AppSidebar() {
};

return (
<div className="flex h-full min-h-0 flex-col bg-surface">
<div className="flex-shrink-0 border-b border-line/70" data-tauri-drag-region>
// 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.
<div className="flex h-full min-h-0 flex-col">
<div className="flex-shrink-0" data-tauri-drag-region>
<SidebarHeader />
</div>
<div className="flex-shrink-0">
<SidebarNav />
</div>
<div className="min-h-0 flex-1 overflow-y-auto border-t border-line/70">
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Flex column so routes that project more than one region can order
them via Tailwind `order-*`. */}
<SidebarSlotOutlet className="flex h-full flex-col" />
</div>
{/* Slim account affordances pinned above the status bar — Rewards then
Expand All @@ -152,7 +164,7 @@ export default function AppSidebar() {
/>
{/* App-wide footer: connectivity status + build/version, pinned to the
bottom of the sidebar. */}
<div className="flex flex-shrink-0 flex-wrap items-center justify-center gap-x-2 gap-y-0.5 border-t border-line px-2 py-0.5">
<div className="flex flex-shrink-0 flex-wrap items-center justify-center gap-x-2 gap-y-0.5 px-3 pb-2 pt-3">
<ConnectionIndicator />
&middot;
<span className="text-[10px] text-content-faint">
Expand Down
12 changes: 6 additions & 6 deletions app/src/components/layout/shell/CollapsedNavRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}`}>
<NavIcon id="home" className="h-5 w-5" />
</button>
Expand Down Expand Up @@ -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'
}`}>
<NavIcon id={tab.id} className="h-5 w-5" />
{showBadge && (
Expand All @@ -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'
}`}>
<NavIcon id="settings" className="h-5 w-5" />
</button>
Expand Down
49 changes: 49 additions & 0 deletions app/src/components/layout/shell/ContentSurface.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ContentSurface>
<p>routed page</p>
</ContentSurface>
);
expect(screen.getByText('routed page')).toBeTruthy();
});

it('frames the surface as an inset rounded card by default', () => {
render(<ContentSurface>body</ContentSurface>);
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(<ContentSurface unframed>body</ContentSurface>);
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(<ContentSurface>body</ContentSurface>);
expect(screen.getByTestId('app-content-surface').className).toContain('min-h-0');
rerender(<ContentSurface unframed>body</ContentSurface>);
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');
});
});
Loading
Loading