Skip to content

feat(app): make the sidebar persistent and give it a Recents section - #173

Merged
quickbeard merged 6 commits into
mainfrom
persistent-sidebar-recents
Aug 10, 2026
Merged

quickbeard merged 6 commits into
mainfrom
persistent-sidebar-recents

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Issue for this PR

Closes #

Type of change

  • New feature

What does this PR do?

Reshapes the desktop home screen around one always-visible sidebar.

The project column carrying Projects plus Settings and Help was an <aside> owned by NewHome, so it only existed on / — opening a session left the titlebar tab bar as the only navigation. It moves into the new-layout shell and now stays on screen for every route.

Inside it:

  • Settings and Help are anchored to the bottom instead of trailing the last project row.
  • A Recents section sits between Projects and them. It leads with a New session row directly above the first session, which calls the same openNewSession() the home screen's "New session" button does — same project resolution (selected → last touched → first), same projects.open + projects.touch + newDraft sequence.
  • The list fills the free height. A ResizeObserver on the list container renders floor((height + 4) / 32) rows, so it reflows on resize and never clips a row mid-way.
  • View all is pinned outside that flexible area, so it is reachable without scrolling at any window height. It opens the existing recent-sessions screen, unchanged apart from losing the column that used to sit beside it.

Below the lg breakpoint the sidebar hides and the home screen keeps its previous stacked layout.

On desktop the app now boots into a new session rather than a list, reusing an existing empty draft instead of accumulating one per launch. This supersedes reopening the screen the window was last on; that session is one click away in Recents and its tab is still restored.

Structure

The moved components and the state both screens need are extracted verbatim into fork-only modules rather than threaded as props, so home.tsx's diff is almost entirely deletions — the cheaper side of an upstream conflict:

  • components/app-sidebar.tsx — the sidebar, plus the seven components moved out of home.tsx
  • pages/home-model.tsx — selection memos, the session-index query, and the actions
  • pages/home-styles.ts — the row/label classes both files use
  • pages/launch-new-session.tsx — the boot redirect

Both NewHome and AppSidebar instantiate the model. That is safe because the selection lives in useLayout().home, the session index is a single TanStack query deduped by key, and the server-normalizing effect is idempotent. Every data-component / data-action hook is preserved byte-identical, since packages/app/e2e selects on them.

How did you verify your code works?

bun typecheck and bun test in packages/app — clean, 624 pass / 0 fail. oxlint on the six touched files adds no new warnings (one pre-existing unused platform in LegacyHome remains, unchanged from main).

Then drove a real desktop build over CDP and measured the DOM rather than eyeballing it:

  • boot → new-session composer, sidebar present
  • View all → 28 home-session-rows, the search field, and the "New session" button — i.e. the untouched recents screen, now framed by the sidebar
  • 1280x800 → 13 recent rows, View all bottom at 686px, Settings at 730px, both well inside the viewport
  • 1280x520 → reflows to 5 rows, View all bottom at 406px, still inside
  • 900x800 → sidebar correctly hidden below lg
  • aside measures exactly 280px, row pitch exactly 32px, matching the constants

One bug this caught. The first version of the boot redirect tested location.pathname === "/" reactively. On a window that restored into a session the redirect had not yet fired, so clicking View all satisfied its condition and bounced straight back to the composer — the recents screen was unreachable. It now pins its decision to the pathname the window came up on and disarms the moment anything navigates. Re-verified after the fix; the numbers above are from that run.

Not verified: the browser surface. The redirect is desktop-gated so codev serve is unaffected by it, but I did not run the sidebar there.

Screenshots / recordings

Boot (new session) and after View all — sidebar in both:

boot

view-all

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

DIVERGENCES.md gains two rows (the sidebar extraction across layout-new.tsx / home.tsx / the i18n dicts, and the boot redirect mount), both spelling out how to re-apply on an upstream conflict. The four new modules are fork-only and untracked there. CHANGELOG.md gets two entries under Unreleased.

🤖 Generated with Claude Code

quickbeard and others added 6 commits August 10, 2026 10:16
The project column with Settings and Help was an <aside> owned by the home
page, so opening a session left the tab bar as the only navigation. It moves
into the new-layout shell and now stays on screen everywhere.

Settings and Help anchor to the bottom of the column. A Recents section sits
between them and Projects: a New session row (the same openNewSession the home
screen's button calls), a list sized to the free height by measuring its
container, and a View all row pinned outside the flexible area so it is always
reachable without scrolling. View all opens the existing recent-sessions
screen, unchanged apart from losing the column beside it.

The moved components and the state both screens need are extracted verbatim
into fork-only modules rather than threaded as props, so home.tsx's diff is
almost entirely deletions. Both callers instantiate the model: the selection
lives in shared context, the session index is one TanStack query deduped by
key, and the server-normalizing effect is idempotent.

On desktop the app now boots into a new session. The redirect pins its decision
to the pathname the window came up on and disarms as soon as anything
navigates, so it cannot hijack a later visit to "/" — an earlier revision did
exactly that and bounced View all straight back to the composer. Browser-side
there is no launch event distinct from navigating to "/", so it stays off
there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Recents list is flex-1 so it can measure how many rows fit, which meant its
leftover slack sat between the last session and the View all row below it. View
all now renders inside that container, directly after the rows, and the row
budget reserves one slot for it — so the spacing is the same 4px as between
sessions and the slack falls below it instead. Measured at 4px across 520px,
800px and 1000px window heights, with View all and Settings still inside the
viewport at each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… them

The first cut moved seven components out of home.tsx into the new sidebar
module, which made an upstream file's diff 820 lines and guaranteed a conflict
— plus a manual re-port — every time upstream touches one of those rows.

They go back where upstream keeps them and simply gain an `export`:
HomeUtilityNav, HomeServerRow, HomeProjectList, HomeProjectEmpty,
HomeRecentlyClosedRow, and the three row/label classes. app-sidebar.tsx imports
and composes them, and now holds only what is genuinely new — the column shell,
the Recents section and its row. home-styles.ts is gone; its two measuring
constants sit next to the only code that reads them.

home.tsx is down to 36 insertions / 409 deletions, and the deletions are just
HomeProjectColumn (dead once the sidebar owns that composition) and the NewHome
state block that moved to the model. Upstream edits to any exported row now
merge with no conflict. The sub-lg mobile nav is upstream's code again rather
than a fork wrapper.

Re-verified on a cold desktop start: boot lands on the composer, the sidebar
shows 13 recents with a 4px gap before View all, View all renders the 28-row
recents screen, and the count reflows to 5 at 520px height.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home toggled between "/" — the recent-sessions list — and the last active tab.
Now that the home screen is a new session, that contradicted itself: pressing
Home took you to a list.

goHome selects an already-open draft if there is one and otherwise falls
through to the existing openNewTab() project-resolution chain, so repeated
presses do not stack up draft tabs. The New session button still always makes a
new one; only Home reuses. The pressed state keys on the draft route instead of
"home". The icon, label and mod+b keybind are unchanged, and tabs.toggleHome is
left in place, just uncalled.

Verified on a running app: boot shows the composer with Home pressed, View all
switches to the 28-row recents list with Home unpressed, and pressing Home
returns to the composer and keeps returning there — draft-tab count unchanged
across three presses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The draft screen painted bg-deep — the window-chrome tone — while sessions and
the recents list sit on a raised bg-base card. Full width that was merely flat;
next to the always-visible sidebar it read as empty space rather than as the
screen you are on.

NewSessionDesignView switches to bg-base and its wrapper in new-session.tsx
takes the bg-base + elevation-raised pair SessionPanelFrame applies with
`newLayout raised`. Confirmed identical at runtime rather than by eye: both
cards compute to rgb(255,255,255), the same eight-layer shadow, and a 10px
radius.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the titlebar

The DEV/BETA tag sat at the far left of the v2 titlebar, pushing the Home
button 45px to the right of where the row starts. It moves to the foot of the
sidebar, under Help.

ChannelIndicator gains an export and is rendered by AppSidebar rather than
relocated, so the badge markup stays where upstream keeps it; layout-new passes
debugTools to the sidebar as well, which keeps the dev badge's debug-bar toggle
working. Titlebar keeps the prop because the legacy titlebar still renders the
badge inline — only the v2 branch changed.

The sidebar guards on the channel rather than wrapping unconditionally, so on
prod, where the indicator renders nothing, the column's gap-4 does not leave a
hole behind.

Verified on three real builds (the channel is a build-time define, so each
needed its own run):

  dev   DEV badge at the sidebar foot, still toggles the debug bar, Home x=92
  beta  BETA badge in the same place, Home x=92
  prod  no badge; Help's bottom lands exactly where the badge's was (762px),
        Recents reclaims the row, Home x=92

x=92 is the leftmost content position on macOS: 84px of traffic-light inset
plus the row's 8px padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 40cd28f into main Aug 10, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the persistent-sidebar-recents branch August 10, 2026 04:35
quickbeard added a commit that referenced this pull request Aug 10, 2026
…itlebar (#175)

* feat(app): add a sidebar collapse toggle and a Search button to the titlebar

The sidebar became permanent chrome in #173 with no way to dismiss it, and the
recents list was reachable only from its own View all row.

The toggle leads the titlebar — ahead of the Windows/Linux app menu, so it is
the first item on every platform. Collapsed hides the column outright rather
than narrowing it to a rail; the state persists globally, so it survives a
relaunch. Search sits right of Home, navigates to the recents list like View
all, and focuses its search field.

Collapse state lives in a fork-only context so the titlebar button and the
sidebar can share it. useAppSidebar() returns undefined outside a provider
rather than throwing: useV2Titlebar() is just newLayoutDesigns(), so this
titlebar also renders inside the legacy shell, which has no provider and no
sidebar of this kind — the toggle is hidden there by construction.

Search hands off through a ?focus=search param that NewHome consumes and
clears, mirroring how new-session.tsx handles ?prompt. The focus ref had to
become a signal: HomeSessionSearch binds it from onMount, which runs after
NewHome's effects, so the first attempt no-opped against an undefined ref and
cleared the param anyway.

No keyboard shortcut, so mod+b keeps its current meaning.

Verified on a running build: button order Toggle(92) Home(134) Search(176);
collapse and restore both track the pressed state; collapsed survives a reload;
Search focuses the input both from a session and from / itself. With the new
layout switched off, the legacy shell renders its own rail, no toggle, and
throws nothing. 624 unit tests and the 5 smoke e2e tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(app): register sidebar.toggle so View → Toggle Sidebar works

The app menu dispatches command id "sidebar.toggle", and only LegacyLayout ever
registered it — so on the new layout the item did nothing. AppSidebarProvider
now registers it with no keybind, since mod+b belongs to Home here rather than
to the sidebar as it does in the legacy layout.

It lives on the provider rather than on AppSidebar so that collapsing the
sidebar does not unregister the one command that brings it back.

Verified through the command registry, which is the path the menu item takes:
opened the palette with mod+k, typed "toggle sid", got "Toggle sidebar" as a
match, and selecting it collapsed the sidebar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
quickbeard pushed a commit that referenced this pull request Aug 11, 2026
#173, #175 and #179 shipped with no tests; #181 and #183 already carried
their own, so this fills the gap left by the other three.

Unit (test-browser, since home-model reaches kobalte through the directory
picker and will not import under the server condition):

- buildHomeSessionRecords: directory filtering, pathKey normalisation so a
  Windows path and a trailing slash are one directory, sort by last update
  with a created-time fallback, dedupe keeping the fresher copy, sandbox and
  projectID resolution, and dropping a session no project claims.
- descendantIDs: the subtree a delete evicts, including cycle safety. Exported
  for this, alongside buildHomeSessionRecords which was already exported.
- session-share-request: the row-to-timeline share handoff consumes once, and
  a request meant for another session is not swallowed.

E2E:

- sidebar-persistence: the column survives opening a session, orders recents
  newest first, opens the session a row names, and View all and New session
  both work from inside a session.
- sidebar-toggle-search: the titlebar toggle hides and restores the column,
  survives a reload and a route change, and Search lands on the recents list
  with its field focused and the ?focus=search param cleared.
- session-actions-menu: the four actions on both lists, Share hidden when the
  server disables sharing, rename in place without opening the session,
  cancel sending nothing, and delete asking first.

Two defects turned up while writing these, both left unfixed and documented
where they bite:

- Clicking a Recents row opens the session but never marks the row as open;
  the marker only appears once the tab list is read back on a fresh mount.
  Arriving by URL marks it immediately, so the state is right and the click
  path fails to re-run the memo.
- The first activation of a row's actions menu is swallowed. Four clicks give
  closed, open, closed, open, and focus + Enter is swallowed the same way, so
  a user's first click on the dots lands on nothing too.

623 unit, 45 browser-condition unit and all 110 e2e regression tests pass;
tsgo -b and the e2e project both typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
quickbeard added a commit that referenced this pull request Aug 11, 2026
* test(app): cover the sidebar, titlebar and session-row menu PRs

#173, #175 and #179 shipped with no tests; #181 and #183 already carried
their own, so this fills the gap left by the other three.

Unit (test-browser, since home-model reaches kobalte through the directory
picker and will not import under the server condition):

- buildHomeSessionRecords: directory filtering, pathKey normalisation so a
  Windows path and a trailing slash are one directory, sort by last update
  with a created-time fallback, dedupe keeping the fresher copy, sandbox and
  projectID resolution, and dropping a session no project claims.
- descendantIDs: the subtree a delete evicts, including cycle safety. Exported
  for this, alongside buildHomeSessionRecords which was already exported.
- session-share-request: the row-to-timeline share handoff consumes once, and
  a request meant for another session is not swallowed.

E2E:

- sidebar-persistence: the column survives opening a session, orders recents
  newest first, opens the session a row names, and View all and New session
  both work from inside a session.
- sidebar-toggle-search: the titlebar toggle hides and restores the column,
  survives a reload and a route change, and Search lands on the recents list
  with its field focused and the ?focus=search param cleared.
- session-actions-menu: the four actions on both lists, Share hidden when the
  server disables sharing, rename in place without opening the session,
  cancel sending nothing, and delete asking first.

Two defects turned up while writing these, both left unfixed and documented
where they bite:

- Clicking a Recents row opens the session but never marks the row as open;
  the marker only appears once the tab list is read back on a fresh mount.
  Arriving by URL marks it immediately, so the state is right and the click
  path fails to re-run the memo.
- The first activation of a row's actions menu is swallowed. Four clicks give
  closed, open, closed, open, and focus + Enter is swallowed the same way, so
  a user's first click on the dots lands on nothing too.

623 unit, 45 browser-condition unit and all 110 e2e regression tests pass;
tsgo -b and the e2e project both typecheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(app): drop the workarounds #185 makes unnecessary

Both specs carried a detour around a defect, each with a comment explaining
why. #185 fixes both, so the detours are now dead weight and the comments are
wrong.

- session-actions-menu: openRowMenu clicked a second time when the first was
  swallowed. One click now, and the helper no longer needs the page.
- sidebar-persistence: "marks the row whose session is already open" reloaded
  before asserting data-selected. It asserts on the click that opened the
  session.

The hover in openRowMenu stays. That one is not a workaround: the trigger is
pointer-events-none until its row is hovered, and Playwright hit-tests before
it moves the mouse.

Checked both ways rather than assumed. On #185's branch the three specs pass
20/20; on main without it, both edited tests fail — the menu never reports
aria-expanded=true, and no row is ever marked selected. So this must merge
after #185.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Quickbeard <quickbeard@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant