diff --git a/CLAUDE.md b/CLAUDE.md
index 8fd2d671..116225c9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -191,10 +191,13 @@ the tag inside `agents.config` — `roster::parse_and_validate` is the only guar
and `update_agent` validates the *post-merge* state; and an `allowed_tools` list
without an `mcp_config_path` is silently inert at spawn time, so it's rejected.
-**Nav rail.** `ui-store.activeSection` (`'board' | 'roster'`, persisted) drives
-`layout/nav-rail.tsx`. Deliberately **not** `viewMode`, which means "is the chat
-panel open" and is load-bearing via `isChatOpen`. Orchestrator is not a section
-yet — it's still a dock panel inside `Board` with its own geometry.
+**Section nav.** `ui-store.activeSection` (`'board' | 'roster'`, persisted)
+drives `layout/section-switcher.tsx`, which renders as icon buttons on the LEFT
+of the workspace tab bar — same recipe as the right-hand cluster
+(`h-8 w-8`, motion scale, 20x20 solid icon, `bg-accent/15 text-accent` active).
+Deliberately **not** `viewMode`, which means "is the chat panel open" and is
+load-bearing via `isChatOpen`. Orchestrator is not a section yet — it's still a
+dock panel inside `Board` with its own geometry.
**Not wired yet (v2):** columns referencing an agent, execution, skill injection
into the CLI, MCP/`/api/*` tools, RAG. Spec:
diff --git a/src/app.tsx b/src/app.tsx
index 6129f9d3..1eb1763d 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -14,7 +14,6 @@ import { useAgentStreamingSync } from '@/hooks/use-agent-streaming-sync'
import { useAutoDetectClis } from '@/hooks/use-cli-path'
import { useUpdater } from '@/hooks/use-updater'
import { Board } from '@/components/layout/board'
-import { NavRail } from '@/components/layout/nav-rail'
import { RosterView } from '@/components/roster/roster-view'
import { CliHealthBanner } from '@/components/layout/cli-health-banner'
import { WorkspaceSetup } from '@/components/layout/workspace-setup'
@@ -174,16 +173,10 @@ function App() {
) : showSetup ? (
+ ) : activeSection === 'roster' ? (
+
) : (
- // The rail sits inside the content slot, below the workspace tab bar
- // and after the setup/error branches — there is nothing to navigate
- // between until a workspace exists.
-
-
-
- {activeSection === 'roster' ? : }
-
-
+
)}
diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx
deleted file mode 100644
index 259cce79..00000000
--- a/src/components/layout/nav-rail.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { ReactNode } from 'react'
-import { Tooltip } from '@/components/shared/tooltip'
-import { useUIStore, type AppSection } from '@/stores/ui-store'
-
-/**
- * Left nav rail — switches the top-level app section.
- *
- * Generic over its item list so adding a section later (Orchestrator, once its
- * dock geometry is untangled from Board) is one entry, not a rewrite.
- *
- * Styling follows the settings-panel nav: `bg-accent/10 text-accent` selected,
- * muted otherwise. Cursor is set inline rather than via `cursor-pointer` —
- * Tailwind cursor classes don't apply reliably in macOS WKWebView (see the
- * pitfalls section in CLAUDE.md).
- */
-
-type RailItem = {
- value: AppSection
- label: string
- icon: ReactNode
- testId: string
-}
-
-function BoardIcon() {
- return (
-
- )
-}
-
-function RosterIcon() {
- return (
-
- )
-}
-
-const RAIL_ITEMS: readonly RailItem[] = [
- { value: 'board', label: 'Board', icon: , testId: 'nav-rail-board' },
- { value: 'roster', label: 'Roster', icon: , testId: 'nav-rail-roster' },
-]
-
-export function NavRail() {
- const activeSection = useUIStore((s) => s.activeSection)
- const setActiveSection = useUIStore((s) => s.setActiveSection)
-
- return (
-
- )
-}
diff --git a/src/components/layout/nav-rail.test.tsx b/src/components/layout/section-switcher.test.tsx
similarity index 61%
rename from src/components/layout/nav-rail.test.tsx
rename to src/components/layout/section-switcher.test.tsx
index 9ba9787f..12b5a44f 100644
--- a/src/components/layout/nav-rail.test.tsx
+++ b/src/components/layout/section-switcher.test.tsx
@@ -1,26 +1,24 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
-import { NavRail } from './nav-rail'
+import { SectionSwitcher } from './section-switcher'
import { useUIStore } from '@/stores/ui-store'
-describe('NavRail', () => {
+describe('SectionSwitcher', () => {
beforeEach(() => {
cleanup()
useUIStore.setState({ activeSection: 'board' })
})
it('marks the active section and switches on click', () => {
- render()
+ render()
- const board = screen.getByTestId('nav-rail-board')
- const roster = screen.getByTestId('nav-rail-roster')
- expect(board).toHaveAttribute('aria-current', 'page')
- expect(roster).not.toHaveAttribute('aria-current')
+ expect(screen.getByTestId('section-board')).toHaveAttribute('aria-pressed', 'true')
+ expect(screen.getByTestId('section-roster')).toHaveAttribute('aria-pressed', 'false')
- fireEvent.click(roster)
+ fireEvent.click(screen.getByTestId('section-roster'))
expect(useUIStore.getState().activeSection).toBe('roster')
- expect(screen.getByTestId('nav-rail-roster')).toHaveAttribute('aria-current', 'page')
+ expect(screen.getByTestId('section-roster')).toHaveAttribute('aria-pressed', 'true')
})
it('does not disturb viewMode', () => {
@@ -28,9 +26,9 @@ describe('NavRail', () => {
// chat panel open" and is load-bearing via isChatOpen. Conflating them
// would close the chat panel every time you switched sections.
useUIStore.setState({ viewMode: 'chat', activeTaskId: 't1' })
- render()
+ render()
- fireEvent.click(screen.getByTestId('nav-rail-roster'))
+ fireEvent.click(screen.getByTestId('section-roster'))
expect(useUIStore.getState().viewMode).toBe('chat')
expect(useUIStore.getState().activeTaskId).toBe('t1')
diff --git a/src/components/layout/section-switcher.tsx b/src/components/layout/section-switcher.tsx
new file mode 100644
index 00000000..3615766c
--- /dev/null
+++ b/src/components/layout/section-switcher.tsx
@@ -0,0 +1,80 @@
+import type { ReactNode } from 'react'
+import { motion } from 'motion/react'
+import { Tooltip } from '@/components/shared/tooltip'
+import { useUIStore, type AppSection } from '@/stores/ui-store'
+
+/**
+ * Top-bar section switcher — Board / Roster.
+ *
+ * Lives on the left of the workspace tab bar, mirroring the icon-button cluster
+ * on the right. Deliberately uses the identical recipe to `AddTabButton` /
+ * `SettingsButton` / `ShowArchivedButton` (h-8 w-8, motion scale, 20x20 solid
+ * icon) so the two clusters read as one row of controls rather than two systems.
+ *
+ * Generic over its item list, so adding a section later (Orchestrator, once its
+ * dock geometry is untangled from Board) is one entry.
+ */
+
+type SectionItem = {
+ value: AppSection
+ label: string
+ icon: ReactNode
+ testId: string
+}
+
+/** Solid 20x20 heroicons-style glyphs — the tab bar's established icon shape. */
+function BoardIcon() {
+ return (
+
+ )
+}
+
+function RosterIcon() {
+ return (
+
+ )
+}
+
+const SECTIONS: readonly SectionItem[] = [
+ { value: 'board', label: 'Board', icon: , testId: 'section-board' },
+ { value: 'roster', label: 'Roster', icon: , testId: 'section-roster' },
+]
+
+export function SectionSwitcher() {
+ const activeSection = useUIStore((s) => s.activeSection)
+ const setActiveSection = useUIStore((s) => s.setActiveSection)
+
+ return (
+