home-widget-rail.md13.1 KBView on GitHub
# The home widget rail — a configurable second column

## 1) Introduction

The `/agent` home is one centred column: greeting → composer → daily agenda → agents. Everything
the user might want at a glance is stacked *under* the composer, so the day, the agents and any
metric all compete for the same vertical space and only the first one is above the fold.

This splits the home into **two columns**: the chat column keeps the greeting, the composer and
the daily agenda; a narrower **widget rail** on the right holds a user-configurable list of
widgets. The rail is the answer to "what do I want to see every morning without scrolling".

Two widgets ship in the rail by default:

- **Upcoming meetings** — extracted out of the daily-agenda card, which currently owns both the
  tasks and the meetings, and given the rail's full height.
- **Home agents** — a hand-picked shortlist of agents, empty until the user adds to it.

Two more are available to add: **Pipeline** and **Statistics** (response time, follow-up time,
emails sent and total actions, as four rows of one table — they began as four separate tiles,
which cost four bordered cards to say four numbers and left them uncomparable).

Two decisions taken up front, both narrowing:

- **The agenda card stays in the chat column, tasks only — and always expanded.** Meetings
  leave it for the rail and it goes back to one full-width column of tasks. The fold went with
  them: the clamp existed to keep the agent list below it reachable without a scroll, and that
  list has now left the hero too (§3.5), so a clamp would only hide the tasks the screen is for.
- **Every metric tile reads a fixed rolling 30-day window.** No range control anywhere. This is
  what lets the whole rail share ONE `getOverview` call — a per-tile range would turn five tiles
  into five queries, and the rail is a glance surface, not an analytics screen. `/statistics`
  already exists for rescoping.

## 2) Present state

### 2.1 Architecture

```text
EmbeddedCedarChat
└── scroll column  (flex-col, overflow-y-auto on the hero)
    ├── AgentHomeHero            greeting            mx-auto max-w-[100ch]
    ├── (message list)           hidden on the hero
    ├── (suggestions bar)
    ├── COMPOSER                 motion.div          mx-auto max-w-[100ch]   ← must not remount
    └── AgentHomeBelowChat       pills · HomeAgenda · AgentsList
                                 └── HomeAgenda  ── one card ──┬── AgendaDocument (tasks)
                                                               └── AgendaMeetings (meetings)
```

Every hero row centres itself independently at `max-w-[100ch]`. There is no shared row wrapper,
which is deliberate: the composer belongs to the chat, and `AgentHomeHero` / `AgentHomeBelowChat`
are split around it precisely so the composer keeps ONE stable position in the tree across the
hero → chat transition. Anything this design does to the layout has to preserve that.

### 2.2 What already exists, and is reusable

| Need | Already there |
|---|---|
| Per-user persisted config | `useSectionCollapsed` → `settings.save` / `userSettingsSchema` |
| Meetings list | `AgendaMeetings` (`compact`, `openTarget`, own-event filter) |
| Agent cards | `AgentsListView` / `AgentCard` in `AgentsList.tsx` |
| Agent avatars | `components/icons/agent-avatar` |
| Create an agent | `trpc.agent.create` |
| **Every metric below** | `trpc.statistics.getOverview` — ONE call |

`statistics.getOverview` is the find that shapes this design. It already returns, in a single
60s-cached query:

- `pipelineStats: { status, count, totalDealValue, avgDealValue }[]` → **Pipeline**
- `responseTime` / `followUpTime` (`TimeMetric`) → **Statistics** rows 1–2
- `activityTimeline: { date, emailInbound, emailOutbound, … }[]` → **Statistics** row 3
- `totalEvents`, `totalConversations`, `winRate` → **Statistics** row 4

**The metric widgets cost one request between them.** The rail fetches the overview once and
each tile is a pure projection of it. Any design where each tile owns its own query is wrong
here — including the Pipeline widget's AOP scope, which is therefore read at the RAIL and
passed into the shared call rather than fetched inside the tile.

#### `pipelineStats` is not "deals" until it is scoped

`computePipelineStats` groups over **every** `crm_conversations` row the user owns, filtered by
AOP only when an `aopId` is passed. Unscoped, it counts cold inbound, vendors, recruiting, spam
and "unknown" as deals. Measured on one real account:

| AOP | Conversations | With a deal value |
|---|---|---|
| Cold inbound | 426 | 14 |
| **Deals** | **124** | **41** |
| Vendors | 59 | 0 |
| Professional relationships | 58 | 3 |
| Unknown / Spam / Recruiting / Other | 121 | 1 |

An unscoped Pipeline widget would report **788 deals** against an actual pipeline of 124 — and
since non-deal conversations have a null `deal_value`, each adds 1 to the count and 0 to the
ACV, so the average silently collapses too.

There is **no type column** on `agent_operating_procedures` — an AOP is a user-named category —
so nothing can infer "the deal one" on the user's behalf. The widget therefore carries its own
AOP selection (`homePipelineAopId`) and NAMES the scope in its header, defaulting to the honest
label "All conversations" rather than to a number that quietly is not a deal figure.

## 3) Designed state

### 3.1 Architecture

```text
EmbeddedCedarChat
└── hero row  (flex)                                    ← NEW, always rendered
    ├── scroll column  (flex-1, overflow-y-auto)        ← unchanged children
    │   ├── AgentHomeHero
    │   ├── COMPOSER                                     ← same tree position
    │   └── AgentHomeBelowChat  ── HomeAgenda (tasks only) · AgentsList
    └── HomeWidgetRail  (w-[21rem], hero only)      ← adjacent to the column, not the viewport edge
        ├── WidgetFrame "Upcoming meetings"  → AgendaMeetings
        ├── WidgetFrame "Agents"             → HomeAgentsWidget → AgentPickerDialog
        └── … user-added widgets
```

**The row wrapper is always rendered, never conditional.** Only the rail inside it is gated on
`isHomeHero`. Mounting the wrapper conditionally would change the scroll column's ancestor chain
on the hero → chat transition and remount the composer — the exact failure the hero split exists
to prevent.

**The pair is centred, not right-anchored.** On the hero the row takes `justify-center` and the
chat column is CAPPED at `calc(100ch + 2rem)` (its rows' own max-width plus their `px-4`) rather
than staying greedy. A `flex-1` column eats all the free space and pins the rail to the far edge
of the screen, which reads as a detached side panel; capped, the leftover splits evenly and the
column and the rail sit together as one block with margins either side.

### 3.5 What left the hero

The agent list is gone from under the agenda. Agents are now a **pinned shortlist in the rail**,
not the full set stacked below the day — which is what `AgentsList.tsx` (folder toggles + search
+ card grid) existed to render. With no call site left it was deleted rather than kept as dead
code; `git` has it if the full list is ever wanted on a screen of its own. `useSectionCollapsed`
went the same way once the agenda stopped folding.

### 3.2 Widget configuration

One new settings key, a list of widget ids in display order:

```ts
// apps/server/src/lib/schemas.ts — userSettingsSchema
homeWidgets: z.array(z.string()).optional(),
homeAgentIds: z.array(z.string()).optional(),
```

Two keys, not one, because they answer different questions and change at different rates:
`homeWidgets` is "which tiles, in what order"; `homeAgentIds` is the Agents widget's own
contents. Nesting the second inside the first would make every agent add rewrite the layout.

> **Gotcha (already bitten once):** `settings.save` validates with `userSettingsSchema.partial()`
> and **zod strips unknown keys**. A client write of an undeclared key is silently accepted and
> reads back as nothing. Both keys must land in `schemas.ts` *before* any client writes them.

Storage is a **list of ids**, not a list of objects. A widget's title, icon, size and renderer
live in a client-side registry keyed by id, so changing how a widget looks never needs a
migration, and an id the build no longer knows is skipped on read rather than crashing the rail.

### 3.3 The widget registry

```ts
// modules/home/widgets/registry.ts
export interface HomeWidgetDef {
  id: HomeWidgetId;
  title: string;
  icon: LucideIcon;
  description: string;          // shown in the "Add widget" picker
  Render: ComponentType<{ overview?: StatisticsOverview; isLoading: boolean }>;
  /** True when the tile is a projection of statistics.getOverview. */
  needsOverview?: boolean;
}
```

`DEFAULT_HOME_WIDGETS = ['meetings', 'agents']`.

The rail issues `statistics.getOverview` **only when at least one mounted widget sets
`needsOverview`** — a default rail (meetings + agents) makes no statistics call at all.

### 3.4 Agents widget

Empty by default, by design: a home shortlist the user did not choose is just the agent list
again, one column narrower. Empty state and hover both surface **Add agent**, opening
`AgentPickerDialog` — a grid of the same `AgentCard` the home list uses, with **Create new agent**
as the first card (`trpc.agent.create`, then straight into the new agent's Config).

Folders are untouched. This widget is a *selection*, not a folder: an agent can sit in `core` and
also be pinned home, and pinning must not re-file it.

## 4) Implementation phases

### Phase 1 — Settings keys + the rail shell

- [x] Add `homeWidgets` + `homeAgentIds` to `userSettingsSchema` and `defaultUserSettings`.
- [x] `useHomeSettingList(key, fallback)` — ONE hook for both lists, same optimistic-write shape as `useSectionCollapsed`. Absent ≠ empty: absent means "never configured" and takes the default, empty is a real choice and is honoured.
- [x] Add the always-rendered hero row wrapper in `EmbeddedCedarChat`; mount `HomeWidgetRail` on the hero only.
- [x] `WidgetFrame` — title, icon, hover actions (remove), consistent card chrome.
- [x] Rail hides below `lg`, where a second column does not fit.

**Tests:** the composer keeps the same DOM node across the hero → chat transition (the remount
guard); the rail renders only on the hero; an unknown stored widget id is skipped, not thrown.

### Phase 2 — Meetings widget, out of the agenda card

- [x] Move `AgendaMeetings` from `HomeAgenda` into the meetings widget; `HomeAgenda` becomes tasks only.
- [x] Widen the agenda card back to one column; drop the two-column grid and the `17rem` track.
- [x] Meetings widget gets the rail's height, no clamp. `AgendaMeetings` gained `hideHeaderTitle` so its built-in "Meetings" heading does not sit under an identical frame title, while keeping the day navigation.

**Tests:** `HomeAgenda` no longer renders meetings and lays its tasks out in ONE column; the
widget renders them with `compact` + `openTarget="conversation"` (the own-event filter is
already covered).

### Phase 3 — Agents widget + picker

- [x] `HomeAgentsWidget` over `homeAgentIds` × `trpc.agent.list`.
- [x] `AgentPickerDialog`: Create-new first, then every agent as a card; TOGGLES membership rather than closing on pick, so pinning three agents is one visit.
- [x] Remove-from-home through the same dialog. Ids of deleted agents are skipped on read.

**Tests:** empty by default; add/remove round-trips through settings; a stale id does not blank
the widget; Create-new mints an agent and navigates to Config.

### Phase 4 — Metric widgets

- [x] `useHomeOverview()` — one `statistics.getOverview` over a fixed rolling 30-day window for
      the whole rail, gated on `needsOverview`. The range is a constant, not a prop: see §1.
- [x] Pipeline (top 5 stages by ACV, total in the headline), Response time, Follow-up time,
      Emails sent, Action stats.
- [x] Shared `StatTile` / `StatRow` primitives + `format.ts`, so the five read as one system.
      `null` prints an em dash, never `0` — "no replies in the window" and "instant replies"
      are different facts.

**Tests:** each tile renders from a fixture overview; one query serves N tiles; a default rail
issues no statistics call.

### Phase 5 — Add / reorder

- [x] "Add widget" at the foot of the rail → picker over the registry. It lists EVERY widget,
      marking what is already in, rather than hiding them: the same dialog is then how you take
      one out, and a picker whose contents change per visit has to be re-learned each time.
- [ ] Reorder (move up/down is enough; drag is not worth the dependency here). NOT BUILT — add
      appends and remove preserves order, which covers arranging the rail by add order.

**Tests:** add appends, remove preserves order, order round-trips.

## 5) Verification

- `timeout 300 pnpm --filter @zero/mail run types`
- `timeout 600 pnpm --filter @zero/mail test modules/home tests/modules/home tests/modules/agentCanvas`
- `timeout 300 pnpm --filter @zero/server exec vitest run src/lib/__tests__`
- Browser: hero → send a message → composer does not lose focus or content (the remount guard).