Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 3 additions & 10 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -174,16 +173,10 @@ function App() {
<SkeletonLoader />
) : showSetup ? (
<WorkspaceSetup />
) : activeSection === 'roster' ? (
<RosterView />
) : (
// 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.
<div className="flex h-full min-h-0">
<NavRail />
<div className="min-w-0 flex-1 overflow-hidden">
{activeSection === 'roster' ? <RosterView /> : <Board />}
</div>
</div>
<Board />
)}
</main>

Expand Down
80 changes: 0 additions & 80 deletions src/components/layout/nav-rail.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
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(<NavRail />)
render(<SectionSwitcher />)

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', () => {
// activeSection is deliberately separate from viewMode, which means "is the
// 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(<NavRail />)
render(<SectionSwitcher />)

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')
Expand Down
80 changes: 80 additions & 0 deletions src/components/layout/section-switcher.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4" aria-hidden="true">
<path d="M3 4.75A1.75 1.75 0 0 1 4.75 3h2A1.75 1.75 0 0 1 8.5 4.75v10.5A1.75 1.75 0 0 1 6.75 17h-2A1.75 1.75 0 0 1 3 15.25V4.75ZM11.5 4.75A1.75 1.75 0 0 1 13.25 3h2A1.75 1.75 0 0 1 17 4.75v6.5A1.75 1.75 0 0 1 15.25 13h-2a1.75 1.75 0 0 1-1.75-1.75v-6.5Z" />
</svg>
)
}

function RosterIcon() {
return (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4" aria-hidden="true">
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
</svg>
)
}

const SECTIONS: readonly SectionItem[] = [
{ value: 'board', label: 'Board', icon: <BoardIcon />, testId: 'section-board' },
{ value: 'roster', label: 'Roster', icon: <RosterIcon />, testId: 'section-roster' },
]

export function SectionSwitcher() {
const activeSection = useUIStore((s) => s.activeSection)
const setActiveSection = useUIStore((s) => s.setActiveSection)

return (
<div className="flex items-center gap-1" role="group" aria-label="Sections">
{SECTIONS.map((item) => {
const isActive = activeSection === item.value
return (
<Tooltip key={item.value} content={item.label} side="bottom">
<motion.button
onClick={() => {
setActiveSection(item.value)
}}
aria-label={item.label}
aria-pressed={isActive}
data-testid={item.testId}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{ cursor: 'pointer' }}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-bg ${
isActive
? 'bg-accent/15 text-accent'
: 'text-text-secondary hover:bg-surface-hover hover:text-text-primary'
}`}
>
{item.icon}
</motion.button>
</Tooltip>
)
})}
</div>
)
}
5 changes: 5 additions & 0 deletions src/components/layout/tab-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useSettingsStore } from '@/stores/settings-store'
import { useChecklistStore } from '@/stores/checklist-store'
import { useTaskStore } from '@/stores/task-store'
import { Tooltip } from '@/components/shared/tooltip'
import { SectionSwitcher } from './section-switcher'
import type { Workspace } from '@/types'
import { CostBadge, MetricsDashboard } from '@/components/usage'
import { AddWorkspaceDialog } from './add-workspace-dialog'
Expand Down Expand Up @@ -328,6 +329,10 @@ export function TabBar() {
return (
<>
<header className="relative z-20 flex h-10 shrink-0 items-center border-b border-border-default bg-surface px-2">
{/* Left: section switcher (Board / Roster). Same button recipe as the
right-hand cluster so the header reads as one row of controls. */}
<SectionSwitcher />

{/* Center: tabs - absolutely positioned for true centering */}
<div className="absolute left-1/2 flex -translate-x-1/2 items-center gap-1">
<DndContext
Expand Down
Loading
Loading