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 ( +
+ {SECTIONS.map((item) => { + const isActive = activeSection === item.value + return ( + + { + 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} + + + ) + })} +
+ ) +} diff --git a/src/components/layout/tab-bar.tsx b/src/components/layout/tab-bar.tsx index 576de575..52f2c18e 100644 --- a/src/components/layout/tab-bar.tsx +++ b/src/components/layout/tab-bar.tsx @@ -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' @@ -328,6 +329,10 @@ export function TabBar() { return ( <>
+ {/* Left: section switcher (Board / Roster). Same button recipe as the + right-hand cluster so the header reads as one row of controls. */} + + {/* Center: tabs - absolutely positioned for true centering */}
- + {label} - + {children} {v2 && ( - + v2 )} @@ -45,25 +43,21 @@ function Row({ ) } -function Token({ children, tone }: { children: React.ReactNode; tone?: 'muted' }) { +function Token({ children }: { children: ReactNode }) { return ( - + {children} ) } -function Empty({ children }: { children: React.ReactNode }) { - return {children} +function Empty({ children }: { children: ReactNode }) { + return {children} } -function SectionLabel({ children }: { children: React.ReactNode }) { +function SectionLabel({ children }: { children: ReactNode }) { return ( -
+
{children}
) @@ -75,7 +69,7 @@ function LlmBlock({ config }: { config: LlmConfig }) { return ( <> - Runtime config · {config.runtime} + Configuration {config.systemPrompt ? ( {config.systemPrompt} @@ -87,19 +81,11 @@ function LlmBlock({ config }: { config: LlmConfig }) { {config.model ? {config.model} : CLI default} - {config.mcpConfigPath ? ( - {config.mcpConfigPath} - ) : ( - No MCP config - )} + {config.mcpConfigPath ? {config.mcpConfigPath} : Not set} {config.allowedTools.length > 0 ? ( - config.allowedTools.map((t) => ( - - {t} - - )) + config.allowedTools.map((t) => {t}) ) : ( All tools allowed )} @@ -114,18 +100,18 @@ function LlmBlock({ config }: { config: LlmConfig }) { // rather than silently dropping it from the list. - missing skill + Missing skill ), ) ) : ( - No skills attached + None attached )} - - Retrieval is a later phase. + + Retrieval lands in a later phase. ) @@ -135,19 +121,15 @@ function ScriptBlock({ config }: { config: ScriptRuntimeConfig }) { const envEntries = Object.entries(config.env) return ( <> - Runtime config · script + Configuration - {config.command ? {config.command} : No command set} + {config.command ? {config.command} : Not set} {config.args.length > 0 ? ( - config.args.map((a, i) => ( - - {a} - - )) + config.args.map((a, i) => {a}) ) : ( - No arguments + None )} @@ -158,11 +140,11 @@ function ScriptBlock({ config }: { config: ScriptRuntimeConfig }) { )) ) : ( - No environment overrides + None )} - - Typed artifacts are a later phase. + + Typed artifacts land in a later phase. ) @@ -183,39 +165,31 @@ export function AgentDossier({ const config = parseAgentConfig(agent.config, agent.runtime) return ( -
+
- {avatar.initials} + {avatar.initials}
-

{agent.name}

- {agent.role ? ( -

{agent.role}

- ) : ( -

No description yet.

- )} - - {agent.runtime} runtime +

{agent.name}

+

{agent.role || 'No description yet.'}

+ + {agent.runtime} - {config.runtime === 'script' ? ( - - ) : ( - - )} + {config.runtime === 'script' ? : } -
+
@@ -224,7 +198,7 @@ export function AgentDossier({ onClick={onDuplicate} data-testid="agent-duplicate" style={{ cursor: 'pointer' }} - className="rounded-lg border border-border-default px-3 py-1.5 text-[13px] text-text-primary transition-colors hover:bg-surface-hover" + className="rounded-lg border border-border-default px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary" > Duplicate @@ -233,7 +207,7 @@ export function AgentDossier({ onClick={onDelete} data-testid="agent-delete" style={{ cursor: 'pointer' }} - className="rounded-lg border border-border-default px-3 py-1.5 text-[13px] text-error transition-colors hover:bg-error/10" + className="rounded-lg border border-border-default px-4 py-2 text-sm text-error transition-colors hover:bg-error/10" > Delete diff --git a/src/components/roster/agent-editor.tsx b/src/components/roster/agent-editor.tsx index e0c840df..00b261a4 100644 --- a/src/components/roster/agent-editor.tsx +++ b/src/components/roster/agent-editor.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import type { ReactNode } from 'react' import type { Agent, AgentConfig, AgentRuntime, LlmConfig, ScriptRuntimeConfig } from '@/types' import { defaultConfigFor, deriveInitials, parseAgentAvatar, parseAgentConfig } from '@/types' import { useRosterStore } from '@/stores/roster-store' @@ -7,12 +8,15 @@ import * as ipc from '@/lib/ipc' /** * Create/edit modal for an agent. * - * `agent: Agent | null` discriminates create from edit (the script-editor + * `agent: Agent | null` discriminates create from edit (the `script-editor` * pattern); the parent owns the refetch via `onSave`. Duplicate is create with * a prefilled `seed`. * - * The form's shape follows the selected runtime, mirroring the dossier — that - * is the whole point of the runtime-typed config. + * The form's shape follows the selected runtime, mirroring the dossier — the + * point of a runtime-typed config is that you never see fields that can't apply. + * + * Chrome (header / scrollable body / footer) matches `script-editor.tsx` so the + * two editors in this app are the same object. */ type Props = { @@ -32,18 +36,27 @@ const GRADIENTS: { from: string; to: string }[] = [ { from: '#122b3a', to: '#1a4a6b' }, ] -function Field({ label, children }: { label: string; children: React.ReactNode }) { +const inputClass = + 'w-full rounded-lg border border-border-default bg-bg px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none' + +function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: ReactNode +}) { return ( -