From 898bf5c1ec6fcf18cdc03cad8b6547e787df50eb Mon Sep 17 00:00:00 2001 From: ANonABento Date: Fri, 21 Aug 2026 16:25:26 -0400 Subject: [PATCH] refactor(roster): section title into the tab bar, flip the split, rebuild the config view ## Section title moves to the tab bar The Roster's own header block (title + caption) is gone. The workspace tab strip now shows the section name instead of workspace tabs when you're not on the board. That is also a correctness fix, not just tidying: agents are GLOBAL, so a workspace selector in the Roster was chrome that changed nothing. Removing it means the tab strip only offers a choice where the choice does something. ## Split flipped The list was taking ~65% and the dossier a fixed 380px, which is backwards -- the list is for scanning, the dossier is where the reading happens. before grid flex-1 | dossier 380px fixed after rail 340px fixed | dossier flex-1 340px fits two tiles per row, which is enough to scan a roster, and hands the rest to the pane that actually has content in it. ## Configuration view rebuilt It was a flat key-value list with a hairline under every row. That shape treats a system prompt, a model name and a filesystem path as if they were peers, which they are not. Now grouped into cards -- the pattern GitHub, Vercel and Stripe settings all use: LLM Instructions (spans, prose) | Model | Tools | Skills | Retrieval(v2) Script Command (spans) | Environment | Outputs(v2) Specifics worth noting: - Card headers carry a count where one is meaningful (tools, skills, args). - Paths, commands and tool ids are click-to-copy, with a check-mark confirmation. Those are values people paste somewhere else. - The system prompt gets `max-w-prose`: the wider pane happily ran it to ~150 characters per line, well past readable. - Delete demoted from a full-width button to an icon -- it sits beside two non-destructive actions and shouldn't carry equal weight. - Portrait moved beside the name instead of above it, so the wider pane doesn't carry a stretched banner. Cut two duplications found by looking at the rendered result: a separate "Arguments" card listed the exact values the Command card already showed, and inside Command a chip row and a full command line said the same thing twice. What's left is the one line that actually gets run, copyable. Also: env keys like RENDER_THREADS were truncating in a 112px label column. Verified: tsc, eslint, IPC check 209, vitest 429, production build clean, and walked all three runtimes plus the MCP/tools and missing-skill cases in Chrome. --- src/components/layout/tab-bar.tsx | 97 +++-- src/components/roster/agent-dossier.tsx | 405 ++++++++++++++------- src/components/roster/roster-view.test.tsx | 18 +- src/components/roster/roster-view.tsx | 36 +- 4 files changed, 354 insertions(+), 202 deletions(-) diff --git a/src/components/layout/tab-bar.tsx b/src/components/layout/tab-bar.tsx index 52f2c18e..2c18b9e1 100644 --- a/src/components/layout/tab-bar.tsx +++ b/src/components/layout/tab-bar.tsx @@ -24,6 +24,7 @@ import { useChecklistStore } from '@/stores/checklist-store' import { useTaskStore } from '@/stores/task-store' import { Tooltip } from '@/components/shared/tooltip' import { SectionSwitcher } from './section-switcher' +import { useUIStore } from '@/stores/ui-store' import type { Workspace } from '@/types' import { CostBadge, MetricsDashboard } from '@/components/usage' import { AddWorkspaceDialog } from './add-workspace-dialog' @@ -91,7 +92,9 @@ function SortableTab({ style={{ cursor: 'pointer' }} > - {workspace.name} + + {workspace.name} + {activeTaskCount > 0 && ( {activeTaskCount} @@ -199,7 +202,9 @@ function ShowArchivedButton() { return ( { setShowArchived(!showArchived) }} + onClick={() => { + setShowArchived(!showArchived) + }} aria-label={showArchived ? 'Hide archived workspaces' : 'Show archived workspaces'} aria-pressed={showArchived} whileHover={{ scale: 1.05 }} @@ -212,7 +217,11 @@ function ShowArchivedButton() { > - + @@ -233,7 +242,11 @@ function ChecklistButton() { s.setActive) const reorder = useWorkspaceStore((s) => s.reorder) const remove = useWorkspaceStore((s) => s.remove) + const activeSection = useUIStore((s) => s.activeSection) const getUnviewedCount = useAttentionStore((s) => s.getUnviewedCount) @@ -294,7 +308,9 @@ export function TabBar() { activeWorkspaceId, setActive, remove, - openAddDialog: () => { setShowAddDialog(true) }, + openAddDialog: () => { + setShowAddDialog(true) + }, }) // ─── Drag Handlers ────────────────────────────────────────────────────────── @@ -333,35 +349,42 @@ export function TabBar() { right-hand cluster so the header reads as one row of controls. */} - {/* Center: tabs - absolutely positioned for true centering */} + {/* Center: workspace tabs on the board, the section name everywhere + else. Agents are global, so a workspace selector in the Roster is + chrome that does nothing — showing the section name instead gives + the view the title its own header used to carry. */}
- - - - {sortedWorkspaces.map((workspace) => ( - { - setActive(workspace.id) - }} - /> - ))} - - - - - {draggingWorkspace && } - - + {activeSection !== 'board' ? ( + Roster + ) : ( + + + + {sortedWorkspaces.map((workspace) => ( + { + setActive(workspace.id) + }} + /> + ))} + + + + + {draggingWorkspace && } + + + )}
{/* Right: add workspace + checklist + cost + settings. @@ -382,7 +405,9 @@ export function TabBar() { {activeWorkspaceId && ( { setShowDashboard(true) }} + onOpenDashboard={() => { + setShowDashboard(true) + }} /> )} @@ -400,7 +425,9 @@ export function TabBar() { {showDashboard && activeWorkspaceId && ( { setShowDashboard(false) }} + onClose={() => { + setShowDashboard(false) + }} /> )} diff --git a/src/components/roster/agent-dossier.tsx b/src/components/roster/agent-dossier.tsx index ec778dae..d0f925f5 100644 --- a/src/components/roster/agent-dossier.tsx +++ b/src/components/roster/agent-dossier.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { useState, type ReactNode } from 'react' import type { Agent, LlmConfig, ScriptRuntimeConfig } from '@/types' import { parseAgentAvatar, parseAgentConfig } from '@/types' import { useRosterStore } from '@/stores/roster-store' @@ -6,146 +6,256 @@ import { useRosterStore } from '@/stores/roster-store' /** * The runtime-typed dossier — the load-bearing idea of the roster. * - * Universal fields up top, then a block whose *shape* is chosen by the agent's - * runtime: a script agent shows command/args/env; an LLM agent swaps those for - * system prompt / model / MCP set / skills. + * Universal identity across the top, then configuration grouped into cards + * whose *set* is chosen by the agent's runtime: a script agent gets + * command/arguments/environment; an LLM agent gets instructions/model/tools/ + * skills. Grouping beats one flat key-value list because these fields are not + * peers — a system prompt is prose, a model is a token, an MCP path is a + * filesystem path you'll want to copy elsewhere. * - * Slots that aren't wired yet render visibly disabled with a `v2` tag rather - * than being hidden, so the dossier stays honest about what the app can - * actually do today. + * Cards that can't do anything yet are shown disabled with a `v2` tag rather + * than hidden, so the dossier stays honest about what the app can do today. */ -function Row({ - label, - children, +function Section({ + title, + count, + wide = false, v2 = false, + children, }: { - label: string - children: ReactNode + title: string + count?: number + /** Span both columns — for prose, which reads badly in a narrow column. */ + wide?: boolean v2?: boolean + children: ReactNode }) { return ( -
- - {label} - - - {children} - - {v2 && ( - - v2 - - )} -
+
+

{title}

+ {count !== undefined && count > 0 && ( + + {count} + + )} + {v2 && ( + + v2 + + )} +
+
{children}
+ ) } -function Token({ children }: { children: ReactNode }) { +/** + * A monospace value. Paths, commands and tool ids get click-to-copy, because + * those are things people paste somewhere else. + */ +function CodeValue({ value, copyable = false }: { value: string; copyable?: boolean }) { + const [copied, setCopied] = useState(false) + + if (!copyable) { + return ( + + {value} + + ) + } + return ( - - {children} - + ) } function Empty({ children }: { children: ReactNode }) { - return {children} + return

{children}

} -function SectionLabel({ children }: { children: ReactNode }) { +/** Label/value pair inside a card, for fields that genuinely are peers. */ +function Field({ + label, + wideLabel = false, + children, +}: { + label: string + /** For env var names and other long keys that shouldn't truncate. */ + wideLabel?: boolean + children: ReactNode +}) { return ( -
- {children} +
+ + {label} + + {children}
) } -function LlmBlock({ config }: { config: LlmConfig }) { +function LlmSections({ config }: { config: LlmConfig }) { const resolveSkills = useRosterStore((s) => s.resolveSkills) const skills = resolveSkills(config.skillIds) return ( <> - Configuration - +
{config.systemPrompt ? ( - {config.systemPrompt} - ) : ( - No system prompt — the CLI default applies. - )} - - - {config.model ? {config.model} : CLI default} - - - {config.mcpConfigPath ? {config.mcpConfigPath} : Not set} - - - {config.allowedTools.length > 0 ? ( - config.allowedTools.map((t) => {t}) + // Prose gets a measure cap: the pane is wide enough to run ~150 + // characters per line, which is well past comfortable reading. +

+ {config.systemPrompt} +

) : ( - All tools allowed + No system prompt. The CLI's own default applies. )} -
- +
+ +
+ + {config.model ? ( + + ) : ( + CLI default + )} + + + + +
+ +
+ + {config.mcpConfigPath ? ( + + ) : ( + Not set + )} + + + {config.allowedTools.length > 0 ? ( + config.allowedTools.map((t) => ) + ) : ( + + {config.mcpConfigPath ? 'All tools' : 'All tools (no MCP configured)'} + + )} + +
+ +
{skills.length > 0 ? ( - skills.map((skill, i) => - skill ? ( - {skill.name} - ) : ( - // A skill can be deleted while agents still reference it. Say so - // rather than silently dropping it from the list. - - Missing skill - - ), - ) +
+ {skills.map((skill, i) => + skill ? ( + + {skill.name} + + ) : ( + // A skill can be deleted while agents still reference it. Say + // so rather than silently dropping it from the list. + + Missing skill + + ), + )} +
) : ( - None attached + None attached. )} - - - Retrieval lands in a later phase. - +
+ +
+ Giving an agent its own document set lands in a later phase. +
) } -function ScriptBlock({ config }: { config: ScriptRuntimeConfig }) { +function ScriptSections({ config }: { config: ScriptRuntimeConfig }) { const envEntries = Object.entries(config.env) return ( <> - Configuration - - {config.command ? {config.command} : Not set} - - - {config.args.length > 0 ? ( - config.args.map((a, i) => {a}) +
+ {config.command ? ( + // One line, exactly what gets run, copyable. Listing each argument + // separately as well said the same thing twice. + ) : ( - None + No command set. This agent can't run until one is. )} - - +
+ +
{envEntries.length > 0 ? ( - envEntries.map(([k, v]) => ( - - {k}={v} - - )) +
+ {envEntries.map(([k, v]) => ( + + + + ))} +
) : ( - None + No overrides. The agent inherits the app's environment. )} - - - Typed artifacts land in a later phase. - +
+ +
+ Typed artifacts a downstream agent can consume land in a later phase. +
) } @@ -165,52 +275,71 @@ export function AgentDossier({ const config = parseAgentConfig(agent.config, agent.runtime) return ( -
-
- {avatar.initials} +
+ {/* Identity band — portrait beside the name rather than above it, so the + wider pane doesn't carry a stretched banner across the top. */} +
+
+ {avatar.initials} +
+
+

{agent.name}

+

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

+
+
+ + + +
-

{agent.name}

-

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

- - {agent.runtime} - - - {config.runtime === 'script' ? : } - -
- - - +
+
+ {config.runtime === 'script' ? ( + + ) : ( + + )} +
) diff --git a/src/components/roster/roster-view.test.tsx b/src/components/roster/roster-view.test.tsx index bcf416b5..c64a03fa 100644 --- a/src/components/roster/roster-view.test.tsx +++ b/src/components/roster/roster-view.test.tsx @@ -74,16 +74,18 @@ describe('RosterView', () => { render() fireEvent.click(screen.getByTestId('agent-tile-a1')) - // LLM agent: prompt/model/skills, no command. - expect(screen.getByText('prompt')).toBeInTheDocument() - expect(screen.getByText('model')).toBeInTheDocument() - expect(screen.queryByText('command')).not.toBeInTheDocument() + // LLM agent: instructions / model / tools / skills, and no command. + expect(screen.getByText('Instructions')).toBeInTheDocument() + expect(screen.getByText('Skills')).toBeInTheDocument() + expect(screen.queryByText('Command')).not.toBeInTheDocument() + expect(screen.queryByText('Environment')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('agent-tile-a2')) - // Script agent: command/args/env, no prompt. - expect(screen.getByText('command')).toBeInTheDocument() - expect(screen.getByText('env')).toBeInTheDocument() - expect(screen.queryByText('prompt')).not.toBeInTheDocument() + // Script agent: command / arguments / environment, and no prompt or skills. + expect(screen.getByText('Command')).toBeInTheDocument() + expect(screen.getByText('Environment')).toBeInTheDocument() + expect(screen.queryByText('Instructions')).not.toBeInTheDocument() + expect(screen.queryByText('Skills')).not.toBeInTheDocument() }) it('shows an empty dossier prompt until an agent is picked', () => { diff --git a/src/components/roster/roster-view.tsx b/src/components/roster/roster-view.tsx index 4096dedb..ef832898 100644 --- a/src/components/roster/roster-view.tsx +++ b/src/components/roster/roster-view.tsx @@ -71,22 +71,12 @@ export function RosterView() { return (
- {/* One title bar across both columns — a header that stopped at the grid - left the dossier's content floating with nothing to align to. */} -
-

Roster

-

- Agents you build once and reuse. Assigning them to board columns comes later. -

-
- - {/* Below lg the dossier stacks under the grid rather than disappearing — - hiding it outright meant selecting an agent on a narrow window did - nothing visible at all. */}
{/* Grid */} -
-
+ {/* Browsing rail — deliberately narrow. Scanning happens here; the + reading happens in the dossier. */} +
+
{FILTERS.map((f) => (