# Composer Parity Plan

Bring the 7 hermes-webui composer-bar features into CVC's React/TS dashboard.
Reference impl: `/Users/jkm/Projects/hermes-webui` (FastAPI + vanilla JS in `static/index.html` lines 515-632).
Target frontend: `/Users/jkm/Projects/cvc/cvc/web/src/` (Vite + React + TS).
Target backend: `/Users/jkm/Projects/cvc/cvc/gateway.py` + `cvc/dashboard/*_api.py`.

---

## The 7 Features

1. **Persona switcher** — Composer-bar dropdown listing personas (`default`, `ajay`, `jha`, `robin`, `samantha`, `tina`). Each row shows the persona name + its bound model + skill count badge. Selecting a persona swaps the active system prompt, default model, and loaded skills for the current chat. Source: hermes-webui `personas/` directory; in CVC the closest analogue is `cvc/dashboard/team_api.py` (`CORE_TEAM` roster: SOFIA-01, TINA-01, SAM-01, ROBIN-01).

2. **Workspace switcher** — Dropdown listing every registered workspace by label + absolute path (e.g. `AstroSwarm /Users/jkm/Projects/AstroSwarm`, `cvc /Users/jkm/Projects/cvc`, `Home /Users/jkm`). Selecting one calls `POST /api/workspace/switch`. Backend already exists at `gateway.py:445 /api/workspace/list` and `:453 /api/workspace/switch`. Composer chip lives in `index.html:519` (`composerWorkspaceChip`).

3. **Model picker** — Grouped dropdown by provider, header shows `<Provider> (N models)`, the active model gets a "PRIMARY" pill. Switching calls the existing `POST /api/models/switch`. Catalog comes from `GET /api/models/catalog` (gateway.py:1615) — already grouped by provider.

4. **Reasoning effort dropdown** — Six options identical to webui (`index.html:626-632`): None / Minimal / Low / Medium / High / Extra High (`xhigh`). Persisted per-chat; injected into the LLM call as `reasoning_effort`. No CVC backend yet.

5. **Context window meter** — Live readout `pct% · used / total` (e.g. `24% · 40.0k / 168.0k`) plus auto-compress threshold (`84.0k / 50%`). Used tokens come from current engine `context_window` (gateway.py:709, 1213); total comes from the active model's `context_window` field already present in the catalog payload.

6. **Workspace tree panel** — Collapsible side panel rendering the active workspace's file tree with byte sizes and mtimes. Mirrors `index.html:1144` `workspacePanelHeading`. No CVC endpoint yet — needs `GET /api/workspace/tree`.

7. **Skills count badge** — Small badge on the persona chip + the persona dropdown rows showing `N skills` (e.g. `116 skills`) for the current persona. Reads from a new `GET /api/personas/{id}/skills` (or denormalised count on `GET /api/personas`).

---

## Existing CVC Endpoints (relevant to these 7)

- `GET  /api/workspace/current` — gateway.py:424
- `GET  /api/workspace/list` — gateway.py:445
- `POST /api/workspace/switch` — gateway.py:453
- `GET  /api/models/catalog` — gateway.py:1615 (grouped-by-provider shape already)
- `GET  /api/models/current` — gateway.py:1704
- `POST /api/models/switch` — gateway.py:1720
- `GET  /api/team` — `cvc/dashboard/team_api.py:87` (closest existing thing to personas)
- `GET  /api/providers` — `cvc/dashboard/providers_api.py:88`
- `POST /api/chat` (SSE) — `gateway.py` (chat surface used by `api.chatStream`)

## Endpoint Gaps

- Feature: **Persona switcher** — Have: `/api/team` (4 hardcoded agents). Missing: `GET /api/personas`, `GET /api/personas/{id}`, `POST /api/personas/active`, persistence of active persona per workspace.
- Feature: **Workspace switcher** — Have: list/switch/current. Missing: nothing for the dropdown itself.
- Feature: **Model picker** — Have: catalog + current + switch. Missing: `primary` flag + per-provider `count` already partially in catalog; verify shape includes `provider_label` and `is_primary`.
- Feature: **Reasoning effort** — Have: nothing. Missing: `GET/PUT /api/chat/reasoning_effort` (persists per workspace), and `chat_request.reasoning_effort` plumbed into `AgentLLM` calls.
- Feature: **Context meter** — Have: `context_size` (msg count, not tokens) on `/ws/{id}/cvc/status`. Missing: `GET /api/chat/context_meter` returning `{used_tokens, total_tokens, auto_compact_threshold_tokens, auto_compact_pct}`.
- Feature: **Workspace tree** — Have: nothing. Missing: `GET /api/workspace/tree?path=&depth=&show_hidden=`.
- Feature: **Skills badge** — Have: nothing. Missing: `GET /api/personas/{id}/skills` (or skill count on persona summary).

---

## Backend Tasks

### `cvc/dashboard/personas_api.py` (new file, mirror `team_api.py`)

```python
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException

class PersonaSummary(BaseModel):
    id: str                   # "default" | "ajay" | "jha" | "robin" | "samantha" | "tina"
    name: str
    description: str | None = None
    default_model: str        # e.g. "claude-opus-4.7"
    default_provider: str     # e.g. "copilot"
    skills_count: int
    system_prompt_path: str | None = None

class PersonaDetail(PersonaSummary):
    system_prompt: str
    skills: list[str]         # skill ids/names

class PersonaActive(BaseModel):
    workspace_id: str
    persona_id: str

def register_personas_routes(app: FastAPI) -> None:
    @app.get("/api/personas") -> dict[str, list[PersonaSummary]]: ...
    @app.get("/api/personas/{persona_id}") -> PersonaDetail: ...
    @app.get("/api/personas/{persona_id}/skills") -> dict[str, list[str]]: ...
    @app.get("/api/personas/active") -> PersonaActive: ...
    @app.post("/api/personas/active", body=PersonaActive) -> PersonaActive: ...
```
Persona definitions live in `~/.cvc/personas/{id}.yaml` (system_prompt + default_model + skills list). Active persona stored in workspace settings.

### `cvc/gateway.py` — additions

```python
@app.get("/api/chat/reasoning_effort")
async def get_reasoning_effort() -> dict[str, str]:
    # returns {"effort": "medium"}  one of none|minimal|low|medium|high|xhigh

@app.put("/api/chat/reasoning_effort")
async def set_reasoning_effort(body: ReasoningEffortBody) -> dict[str, str]: ...

class ReasoningEffortBody(BaseModel):
    effort: Literal["none","minimal","low","medium","high","xhigh"]

@app.get("/api/chat/context_meter")
async def get_context_meter() -> ContextMeter: ...

class ContextMeter(BaseModel):
    used_tokens: int
    total_tokens: int            # from active model's context_window
    used_pct: float              # 0..1
    auto_compact_threshold_tokens: int
    auto_compact_threshold_pct: float  # default 0.5
    model: str
    provider: str

@app.get("/api/workspace/tree")
async def get_workspace_tree(
    path: str = "",              # relative to active workspace root
    depth: int = 2,
    show_hidden: bool = False,
) -> WorkspaceTreeNode: ...

class WorkspaceTreeNode(BaseModel):
    name: str
    path: str                    # absolute
    rel_path: str
    is_dir: bool
    size: int                    # bytes
    mtime: float                 # unix ts
    children: list["WorkspaceTreeNode"] | None = None
```

Plumb `reasoning_effort` through `chat_request` body in `/api/chat` and forward to `AgentLLM` (the field is already accepted by Anthropic & Copilot adapters).

### `cvc/gateway.py` — `/api/models/catalog` patch

Add `is_primary: bool` and `provider_label: str` (e.g. `"GitHub Copilot (18 models)"`) to each entry so the picker renders without client-side string munging.

---

## Frontend Components

All under `cvc/web/src/components/composer/`.

### `ComposerBar.tsx`
```ts
interface ComposerBarProps {
  onSend(text: string, opts: ComposerSendOptions): void;
  streaming: boolean;
  onAbort(): void;
}
interface ComposerSendOptions {
  personaId: string;
  workspaceId: string;
  provider: string;
  model: string;
  reasoningEffort: ReasoningEffort;
}
```

### `PersonaChip.tsx`
```ts
interface PersonaChipProps {
  active: PersonaSummary;
  personas: PersonaSummary[];
  onChange(personaId: string): void;
}
```
Renders `<chip>{persona.name} · {persona.default_model} · {persona.skills_count} skills</chip>` + dropdown list with same trio per row.

### `WorkspaceChip.tsx`
```ts
interface WorkspaceChipProps {
  active: WorkspaceInfo;
  workspaces: WorkspaceInfo[];
  onSwitch(workspaceId: string): Promise<void>;
  onTogglePanel(): void;
  panelOpen: boolean;
}
```

### `ModelPicker.tsx`
```ts
interface ModelPickerProps {
  catalog: ModelCatalog;          // already grouped by provider
  current: { provider: string; model: string };
  onSwitch(provider: string, model: string): Promise<void>;
}
```
Render `<group>{provider_label}</group>` headers; show `PRIMARY` pill on `is_primary` row.

### `ReasoningChip.tsx`
```ts
type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
interface ReasoningChipProps {
  effort: ReasoningEffort;
  onChange(effort: ReasoningEffort): void;
}
const LABELS: Record<ReasoningEffort, string> = {
  none: "None", minimal: "Minimal", low: "Low",
  medium: "Medium", high: "High", xhigh: "Extra High",
};
```

### `ContextMeter.tsx`
```ts
interface ContextMeterProps {
  meter: ContextMeter;            // polled every 2s while not streaming, on every chat event while streaming
  compact?: boolean;
}
```
Renders `24% · 40.0k / 168.0k` and a thin progress bar; tooltip shows `auto-compress at 84.0k / 50%`. Use `Intl.NumberFormat` for the `k` formatting.

### `SkillsBadge.tsx`
```ts
interface SkillsBadgeProps {
  count: number;
  personaId: string;
  onClick?(): void;               // open skills panel
}
```

### `WorkspaceTreePanel.tsx`
```ts
interface WorkspaceTreePanelProps {
  rootPath: string;
  open: boolean;
  onClose(): void;
  showHidden: boolean;
  onToggleHidden(v: boolean): void;
  onFileClick?(absPath: string): void;
}
// Uses recursive <TreeNode node={WorkspaceTreeNode}/>; lazy-loads on expand
// via api.workspaceTree(node.rel_path, depth=1).
```

Mount order in `ChatPage.tsx` (replacing lines 142-164 toolbar):
```
<ComposerBar>
  [WorkspaceChip] [PersonaChip] [SkillsBadge]
  [ModelPicker]   [ReasoningChip]
  [textarea]      [ContextMeter] [Send/Stop]
</ComposerBar>
<WorkspaceTreePanel/>   // overlay drawer, left side
```

---

## API Client Additions (`cvc/web/src/lib/api.ts`)

```ts
// personas
personas(): Promise<{ personas: PersonaSummary[] }>;
persona(id: string): Promise<PersonaDetail>;
personaSkills(id: string): Promise<{ skills: string[] }>;
activePersona(): Promise<PersonaActive>;
setActivePersona(workspace_id: string, persona_id: string): Promise<PersonaActive>;

// reasoning effort
getReasoningEffort(): Promise<{ effort: ReasoningEffort }>;
setReasoningEffort(effort: ReasoningEffort): Promise<{ effort: ReasoningEffort }>;

// context meter
contextMeter(): Promise<ContextMeter>;

// workspace tree
workspaceTree(path?: string, depth?: number, showHidden?: boolean): Promise<WorkspaceTreeNode>;

// workspace switcher (already have gatewayWorkspaces; add explicit switch)
workspaceSwitch(workspace_id: string): Promise<OperationResult>;
```

Add corresponding TS types in `cvc/web/src/lib/types.ts`:
`PersonaSummary`, `PersonaDetail`, `PersonaActive`, `ReasoningEffort`,
`ContextMeter`, `WorkspaceTreeNode`. Augment `ModelCatalog` entries with
`is_primary?: boolean` and `provider_label?: string`. Augment `ChatRequest`
with `reasoning_effort?: ReasoningEffort` and `persona_id?: string`.

---

## Theme Tokens to Reuse

Lifted from `ThemeSwitcher.tsx`:

- Trigger button: `Button ghost` from `@nous-research/ui/ui/components/button`, classes `px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground`.
- Dropdown surface: `absolute z-50 min-w-[240px] border border-current/20 bg-background-base/95 backdrop-blur-sm shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]`.
- `dropUp` prop pattern: `dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1"` — use `dropUp` for composer chips since they sit at the bottom of the viewport.
- Section header: `border-b border-current/20 px-3 py-2` + `Typography mondwest text-[0.65rem] tracking-[0.15em] uppercase text-midground/70`.
- Row primitive: `ListItem` from `@nous-research/ui/ui/components/list-item` with `active`/`role="option"`/`aria-selected`.
- Active row indicator: `Check` icon `h-3 w-3 shrink-0 text-midground` toggled by `opacity-100` / `opacity-0`.
- Row label: `Typography mondwest truncate text-[0.75rem] tracking-wide uppercase`.
- Row sub-label (e.g. model name on persona row, abs path on workspace row): `Typography truncate text-[0.65rem] normal-case tracking-normal text-midground/50`.
- Click-outside + Escape close: copy the `useEffect` block from `ThemeSwitcher.tsx:30-49` verbatim into a shared `useDismissableDropdown(open, close)` hook in `cvc/web/src/lib/hooks/useDismissableDropdown.ts`.
- For the `SkillsBadge` swatch pattern: copy `ThemeSwatch` shape (`flex h-4 w-9 shrink-0 overflow-hidden border border-current/20`) for the badge pill.
- For the context meter bar: reuse `border-current/20` border + `bg-midground/10` fill on a `h-1` track.

---

## Smoke Test Checklist

1. **Persona dropdown opens**: click chip → list of ≥6 personas, each row shows name + model + `N skills`.
2. **Persona switch persists**: select `tina`, reload page, chip still shows `tina`; `GET /api/personas/active` returns `tina`.
3. **Workspace switch**: open workspace dropdown, pick `cvc`, chat then asks "what dir am I in?" → assistant resolves to `/Users/jkm/Projects/cvc`.
4. **Model picker grouping**: dropdown shows provider headers like `GitHub Copilot (18 models)`; the row matching `/api/models/current` has a `PRIMARY` pill; switching models updates `/api/models/current`.
5. **Reasoning effort persists**: pick `High`, send a message, inspect outgoing chat payload → `reasoning_effort: "high"`; reload → chip still shows `High`.
6. **Reasoning effort=`xhigh`** maps to `Extra High` label and is accepted by `PUT /api/chat/reasoning_effort` (no 422).
7. **Context meter polls**: meter shows non-zero `used / total` within 2s of page load; matches `(used_tokens / total_tokens)` from `/api/chat/context_meter`.
8. **Context meter updates during streaming**: send a long message, watch `used_tokens` climb; bar widens; threshold tooltip shows `84.0k / 50%` style text.
9. **Workspace tree opens & lazy-loads**: click tree toggle button on workspace chip → drawer slides in; expand a folder → child fetch hits `/api/workspace/tree?path=…&depth=1`; sizes + mtimes render.
10. **Skills badge count matches**: badge on persona chip equals `personas[active].skills_count` and equals `personaSkills(active).skills.length`.
11. **Click-outside + Esc**: opening any dropdown then clicking outside or pressing Esc closes it (shared `useDismissableDropdown` hook).
12. **End-to-end chat**: with `persona=tina, workspace=cvc, model=copilot/claude-opus-4.7, reasoning=medium`, send "list 3 files in cwd" → assistant streams a reply; `done` event arrives; meter updates; no console errors.
