chat-thread-store.md45.1 KBView on GitHub
# Chat Thread Store — agentThreadsSlice: map, views, per-thread input, unified context

## 1) Introduction — goal, present state, future state

We want the chat thread store to work like the mail `threadSlice`: one canonical `Record<id, MessageThread>` map with a single active pointer and several first-class *views* (Recent, Active, Pinned) derived from the map, plus selection/navigation — instead of an ad-hoc tab array. Today the map already exists (`MessagesSlice.threadMap`) but "how it's displayed" is a naive `openChatTabIds: string[]` living in a *different* slice (`uxSlice`), the active pointer is `mainThreadId`, and thread status is scattered across `processingThreadIds` (MessagesSlice) and `finishedChatTabIds` (uxSlice), with `EmbeddedCedarChat` stitching all three together imperatively. We evolve `MessagesSlice` in place: add per-thread status metadata (`status`, `pinned`, `lastActiveAt`, `createdThisSession`) on `MessageThread`, add derived `getThreadsByView('recent'|'active'|'pinned')` selectors plus `selectThread` / `navigateToNextThread` / `navigateToPreviousThread` / `pinThread` / `setThreadStatus`, repoint `EmbeddedCedarChat` / `HomeChatSidebar` / the background-agent badge onto views, and then delete the chat tab arrays from `uxSlice`. Beyond the tab/status refactor we also make each thread own its **input state** (draft `inputContent`) instead of the single global `agentContextSlice.chatInputContent`, **collapse the parallel `@`-mention channel into the committed context set** — selecting an `@`-mention calls `addContextItem` (deleting the `additionalContext` mention entries and the single-provider registry) and mentions render as clickable chips in both composer and transcript (click → `selectedArtifact`) — and **rename `MessagesSlice` → `agentThreadsSlice`**, folding the per-thread parts of `agentContextSlice` into it. `agentConnectionSlice` (transport/streaming/SSE routing) is left as-is. No backend change (all client-derived).

## 2) Present state

### 2.1 Architecture diagram

```text
  MessagesSlice (store/messages/messagesSlice.ts)          uxSlice (ux/uxSlice.ts)
  ┌──────────────────────────────────────────┐            ┌───────────────────────────┐
  │ threadMap: Record<id, MessageThread>      │            │ openChatTabIds: string[]  │
  │ mainThreadId: string                      │            │ finishedChatTabIds:string[]│
  │ processingThreadIds: Set<string>          │            │ add/remove/prependOpenChatTab│
  │ addProcessingThread/removeProcessingThread│            │ add/clearFinishedChatTab   │
  └──────────────────────────────────────────┘            └───────────────────────────┘
        ▲              ▲                                        ▲          ▲
        │ set on send/ │ read                                  │ tab CRUD │ finished
        │ finish       │                                       │          │
  agentConnectionSlice │                                  EmbeddedCedarChat (imperatively
  (sendMessage)        │                                  stitches all three: seed tab,
                       │                                  add mainThreadId, mark finished,
                       │                                  render openTabIds as tabs)
                 HomeChatSidebar ── api.chat.getThreads.query() + local sort  (its own path)
```

### 2.2 Step-by-step walkthrough

1. **Active pointer** — `selectChatThreadId` at [uxSlice.ts:1009](apps/mail/modules/ux/uxSlice.ts) simply returns `state.mainThreadId`. `mainThreadId` is owned by `MessagesSlice` and defaults to `DEFAULT_THREAD_ID`.
   - Shape: `mainThreadId: string`.

2. **The map** — `threadMap` on `MessagesSlice` ([messagesSlice.ts:28](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts)). Each entry:
   ```ts
   interface MessageThread {
     id: string; name?: string; color?: string;
     chatContext?: ChatContext; updatedAt?: string; lastLoaded?: string;
     hasMoreMessages?: boolean; messages: Message[]; activeCanvasId?: string;
   }
   ```

3. **Processing status (scattered, part 1)** — `agentConnectionSlice.sendMessage` calls `state.addProcessingThread(resolvedThreadId)` at [agentConnectionSlice.ts:550](apps/mail/modules/cedar-os/src/store/agentConnection/agentConnectionSlice.ts) on send and `state.removeProcessingThread(resolvedThreadId)` at [agentConnectionSlice.ts:822](apps/mail/modules/cedar-os/src/store/agentConnection/agentConnectionSlice.ts) on finish. These mutate `MessagesSlice.processingThreadIds: Set<string>`.

4. **Tab state (in a different slice)** — `uxSlice` holds `openChatTabIds` / `finishedChatTabIds` with `setOpenChatTabIds` / `addOpenChatTab` / `prependOpenChatTab` / `removeOpenChatTab` / `addFinishedChatTab` / `clearFinishedChatTab` at [uxSlice.ts:836](apps/mail/modules/ux/uxSlice.ts). Plain arrays, no dedup beyond `includes`, no ordering semantics.
   - Shape: `openChatTabIds: string[]; finishedChatTabIds: string[]`.

5. **EmbeddedCedarChat stitches the three together** ([EmbeddedCedarChat.tsx:413-420](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx) subscribes to `openChatTabIds`, `finishedChatTabIds`, `processingThreadIds`):
   - **Seed** ([:435](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx)): on mount, if `openTabIds` empty → `createThread` + `switchThread` + `setOpenChatTabIds([newId])`.
   - **Keep active visible** ([:477](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx)): `addOpenChatTab(mainThreadId)`.
   - **Background agent** ([:489](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx)): `prependOpenChatTab(threadId)`.
   - **Finished transitions** ([:501-510](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx)): diff `prevProcessingRef` vs `processingThreadIds`; a thread that left processing and isn't the active tab → `addFinishedChatTab(tid)`.
   - **Render** ([:720-724](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx)): `openTabIds.map(...)`, per-tab `isTabProcessing = processingThreadIds.has(tid)`, `isTabFinished = finishedTabIds.includes(tid) && !isTabProcessing && tid !== mainThreadId`.
   - Data the tab bar consumes:
     ```ts
     { openTabIds: string[], processing: Set<string>, finished: string[], mainThreadId: string }
     ```

6. **Sidebar has its own list path** — `HomeChatSidebar` ([HomeChatSidebar.tsx](apps/mail/modules/home/components/HomeChatSidebar.tsx)) queries `api.chat.getThreads` and sorts by `updatedAt` locally — a *fourth* notion of "the thread list", disconnected from `threadMap` / tabs.

7. **The gap** — the map is canonical, but there is no first-class "view" over it: display order/eligibility is an imperative `string[]` in `uxSlice`, status is two disjoint structures, and three components each re-derive "the list" differently.

8. **Chat input is global, not per-thread** — `agentContextSlice.chatInputContent` is a single `string | null` at [agentContextSlice.ts:143](apps/mail/modules/cedar-os/src/store/agentContext/agentContextSlice.ts) with a global `setChatInputContent` at [agentContextSlice.ts:283](apps/mail/modules/cedar-os/src/store/agentContext/agentContextSlice.ts). Switching threads shares/loses the draft; the live `additionalContext` mentions are global too, so they leak across threads.

9. **@-mention = a separate context channel + a single-provider registry** — typing `@` queries `getMentionProvidersByTrigger('@')` over a `mentionProviders: Map<string, MentionProvider>`; the only provider registered in production is `useConversationMentionProvider` ([ChatInput.tsx:52](apps/mail/modules/cedar-os/src/cedar-os-components/chatInput/ChatInput.tsx)). On select, `mentionSuggestion.command` ([mentionSuggestion.ts](apps/mail/modules/cedar-os/src/components/chatInput/mentionSuggestion.ts)) calls `provider.toContextEntry` → `addContextEntry('conversations', entry)` (writes `additionalContext['conversations']`, `source:'mention'`) AND inserts a TipTap mention node. So a mention lives in *two* places: `additionalContext` (the structured `data`) and the editor node.
   - The `additionalContext` "subscriptions" mechanism (`useSubscribeStateToAgentContext`) is **unused** — zero call sites; `putAdditionalContext` is used once (ReportsView `openDocument`). The real live context is the manual derivation in `buildMergedContextForMailSendMessage` + mentions.

10. **Mentions are lost on send** — the mention node serializes via `renderMarkdown` → plain `@label` at [useCedarEditor.ts:178](apps/mail/modules/cedar-os/src/components/chatInput/useCedarEditor.ts); `message.content` holds only that text and `MarkdownRenderer` renders it as text. The structured reference survives only in `additionalContext`, only for that one message. This is a *second* context system running parallel to the committed `chatContext.items[]` from the context-set work (see apps/server/docs/chat-context-set.md).

## 3) Designed state

### 3.1 Architecture diagram

```text
  agentThreadsSlice  (store/messages/messagesSlice.ts, renamed) — ONE map, per-thread everything
  ┌────────────────────────────────────────────────────────────────────────────────┐
  │ threadMap: Record<id, MessageThread>                                            │
  │   MessageThread {                                                               │
  │     messages[]                                                                  │
  │     chatContext.items[]        ← committed context set (the ONLY context channel)│
  │     inputContent               ← per-thread draft (was global chatInputContent) │
  │     status:'idle'|'streaming'|'finished' · pinned? · lastActiveAt? · createdThisSession? │
  │   }                                                                             │
  │ activeThreadId · selectThread · navigateToNext/PreviousThread                   │
  │ setThreadStatus · pinThread/unpinThread · getThreadsByView('recent'|'active'|'pinned')│
  │ addContextItem / removeContextItem · setSelectedArtifact                        │
  └────────────────────────────────────────────────────────────────────────────────┘
     ▲ setThreadStatus           ▲ @mention → addContextItem        ▲ read views / items / selectedArtifact
     │ (send/finish)             │ (one channel, no registry)        │
  agentConnectionSlice     ChatInput + mentionSuggestion      ┌──────┴───────────┬────────────────┬───────────────────┐
  (transport/streaming/    (hard-typed kind resolver)    EmbeddedCedarChat   HomeChatSidebar    OpenArtifactPanel
   SSE → responseProcessors)                             tabs = view('pinned') Recent=view('recent') col3 = selectedArtifact
                                                         active = view('active')                    MentionChip click →
                                                         finished = status                          setSelectedArtifact

  REMOVED:  uxSlice openChatTabIds / finishedChatTabIds  ·  MessagesSlice processingThreadIds
            ·  agentContext global chatInputContent  ·  additionalContext 'mention' channel
            ·  mentionProviders registry  ·  useSubscribeStateToAgentContext (dead)
```

### 3.2 Step-by-step walkthrough

1. **Status metadata on the thread** — extend `MessageThread` in [MessageTypes.ts:248](apps/mail/modules/cedar-os/src/store/messages/MessageTypes.ts):
   ```ts
   interface MessageThread {
     …
     status?: 'idle' | 'streaming' | 'finished';
     pinned?: boolean;
     lastActiveAt?: string;        // ISO; bumped on send/receipt
     createdThisSession?: boolean; // set by createThread
   }
   ```

2. **Status setter** — new `setThreadStatus(threadId, status)` on `MessagesSlice` ([messagesSlice.ts](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts)): writes `threadMap[id].status` and, for `'streaming'`, bumps `lastActiveAt`. `processingThreadIds` is kept in Phase 1-3 as a derived mirror, then dropped.
   - After `setThreadStatus('t1','streaming')`:
     ```json
     { "t1": { "status": "streaming", "lastActiveAt": "2026-07-06T00:00:00Z" } }
     ```

3. **Pin/unpin** — `pinThread(id)` / `unpinThread(id)` set `threadMap[id].pinned`. Pinned is the user-curated "keep open" set that replaces the tab array.

4. **Derived views** — `getThreadsByView(view)` on `MessagesSlice`, pure over `threadMap`:
   - `'recent'` → all non-placeholder threads sorted by `lastActiveAt ?? updatedAt` desc.
   - `'active'` → `status === 'streaming'`.
   - `'pinned'` → `pinned === true`, preserving pin order (insertion via `lastActiveAt` or an explicit order field).
   - Returns `MessageThread[]`; callers map to ids/names.
   ```ts
   getThreadsByView('active') // → [{ id:'t1', status:'streaming', … }]
   ```

5. **Selection + navigation** — `selectThread(id)` sets `activeThreadId` (and keeps `mainThreadId` in sync via the alias); `navigateToNextThread()` / `navigateToPreviousThread()` move within `getThreadsByView('recent')` (mirrors `threadSlice.navigateToNext/PreviousThread` at [threadSlice.ts:1636](apps/mail/modules/threads/threadList/store/threadSlice.ts)).

6. **Connection drives status** — `agentConnectionSlice` calls `setThreadStatus(id,'streaming')` at send ([agentConnectionSlice.ts:550](apps/mail/modules/cedar-os/src/store/agentConnection/agentConnectionSlice.ts)) and `setThreadStatus(id,'finished')` at finish ([agentConnectionSlice.ts:822](apps/mail/modules/cedar-os/src/store/agentConnection/agentConnectionSlice.ts)). A thread returns to `'idle'` when its finished badge is cleared (viewed).

7. **EmbeddedCedarChat consumes views** — the tab bar renders `getThreadsByView('pinned')` (union the active thread), per-tab processing = `thread.status === 'streaming'`, finished = `thread.status === 'finished' && id !== activeThreadId`. "Open a tab" = `pinThread(id)`; "close a tab" = `unpinThread(id)`. The seed/keep-visible/background effects at [EmbeddedCedarChat.tsx:435-510](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx) call `pinThread` / `selectThread` instead of the uxSlice actions.

8. **Sidebar consumes the same view** — `HomeChatSidebar` Recent reads `getThreadsByView('recent')` from the store (still hydrated by `api.chat.getThreads` on load), removing its bespoke sort.

9. **uxSlice slimmed** — `openChatTabIds` / `finishedChatTabIds` and their six actions are deleted from [uxSlice.ts:836](apps/mail/modules/ux/uxSlice.ts); `selectChatThreadId` returns `activeThreadId`. `processingThreadIds` is removed from `MessagesSlice` once all reads go through `status`.

10. **Per-thread input state** — draft input moves onto the thread: `MessageThread += inputContent?: string`. `setChatInputContent` / `stringifyEditor` / `useCedarEditor` read+write `threadMap[activeThreadId].inputContent`; switching threads preserves each draft. The global `agentContextSlice.chatInputContent` is removed.
    ```ts
    { "t1": { inputContent: "draft for numeral…" }, "t2": { inputContent: "" } }
    ```

11. **Mentions → explicit context items (one system, two triggers)** — the composer resolves mentions with a small hard-typed resolver on **two triggers**: `@` → `conversation | email_thread | slack_thread`, and `[[` → `file` (reusing the document editor's `[[` file-link at [FileLinkSuggestion.ts:62](apps/mail/modules/documents/file-link/FileLinkSuggestion.ts), same behavior as document mentions). Selecting a mention is an **explicit** attach: `mentionSuggestion.command` calls `addContextItem(activeThreadId, { kind, id, label, addedBy: 'user' })`, committing it to the thread's context set (`items[]`) durably — distinct from *ambient* "what's open on screen," which stays soft/promotable. It stamps `{ kind, id }` on the mention node. The `additionalContext` `mention` channel, the `mentionProviders` Map, and `useConversationMentionProvider` are deleted; `ContextBadgeRow` renders `chatContext.items`.

12. **Unique mention rendering** — the mention node serializes to a structured token (e.g. `[@Label](cedar-item:conversation:<id>)`) instead of `@label`; a `MentionChip` renderer is registered in `MarkdownRenderer` alongside the existing `conversation-widget` handler at [MarkdownRenderer.tsx:148](apps/mail/modules/cedar-os/src/cedar-os-components/chatMessages/MarkdownRenderer.tsx). Clicking a chip calls `setSelectedArtifact({ kind, id })` → opens column 3. The composer badge uses the same chip.

13. **Fold agentContext + rename** — `MessagesSlice` → `agentThreadsSlice`; per-thread input/mention state lives on the thread; `buildMergedContextForMailSendMessage` stays as a util reading the active thread; the dead subscription machinery (`useSubscribeStateToAgentContext`) is dropped.

14. **agentConnectionSlice unchanged** — transport/streaming/SSE routing stays; its only store touchpoint remains the `setThreadStatus` hook from Phase 3.

15. **Composer slash menu (`/`)** — the chat editor gets a `/` command menu (a TipTap Suggestion extension, same mechanism as the mention/file-link suggestions) with **thread commands**: `/new` (create + select a fresh thread), `/fork` (duplicate the active thread's `messages` + `chatContext` into a new thread and select it — a branch point), `/clear` (empty the active thread's messages, keeping the thread + its context set). Commands run on select and are stripped from the editor text (never sent as message content).

### 3.3 Schema

Full schema (the designed `agentThreadsSlice` client types; new/changed fields flagged):

```ts
// ── Context set ──────────────────────────────────────────────────────────────
// A "deal" is a conversation — there is no separate 'deal' kind.
type ContextKind = 'conversation' | 'email_thread' | 'slack_thread' | 'file';

interface ContextItem {
  kind: ContextKind;
  id: string;                  // ref → backend entity (see relationship diagram)
  label?: string;              // denormalized display name for chips/panel
  addedBy?: 'user' | 'agent';  // provenance ONLY — no removal asymmetry; the agent may remove any item
  addedAt?: string;            // ISO
  // removed: `pinned` (no pinned-immune remove) and `primary` (now a dedicated ChatContext slot)
}

interface ChatContext {
  summary?: string;                                    // agent-generated thread summary (set-thread-title)
  primaryConversation?: { id: string; name?: string }; // the ONE primary — singular by construction
  items?: ContextItem[];                               // additional attached refs (incl. any secondary conversation)
  // legacy singular slots removed (page/conversation/email/task/canvas); a one-time migration folds old rows in
}

// ── Message ──────────────────────────────────────────────────────────────────
interface Message {            // BaseMessage (+ per-type extensions)
  id: string;
  role: 'user' | 'assistant' | 'bot';
  content: string;
  type: string;                // 'text' | 'cited-text' | 'todolist' | …
  createdAt?: string;
  metadata?: Record<string, unknown>;
  citations?: Citation[];
  // The context item this message is ABOUT: the item mentioned/attached that turn,
  // else the thread's primaryConversation. Drives message→artifact focus.
  openContext?: { kind: ContextKind; id: string };
}

// ── Thread ───────────────────────────────────────────────────────────────────
interface MessageThread {
  id: string;
  name?: string;               // agent-set title
  color?: string;
  messages: Message[];                                  // 1:N
  chatContext?: ChatContext;                            // 1:1 (primaryConversation + items[])
  inputContent?: string;       // NEW — per-thread draft (replaces global agentContext.chatInputContent)
  status?: 'idle' | 'streaming' | 'finished';  // NEW — replaces processingThreadIds + finishedChatTabIds
  pinned?: boolean;            // NEW — THREAD pin (open-in-tab / views); distinct from the removed ContextItem.pinned
  lastActiveAt?: string;       // NEW — recency ordering for 'recent'/'active' views
  createdThisSession?: boolean;// NEW — auto-surface a just-created thread
  updatedAt?: string; lastLoaded?: string; hasMoreMessages?: boolean; activeCanvasId?: string;
}

// ── Slice ────────────────────────────────────────────────────────────────────
interface AgentThreadsSlice {  // was MessagesSlice
  threadMap: Record<string, MessageThread>;                    // canonical map
  activeThreadId: string;                                       // NEW name (was mainThreadId)
  selectedArtifact: { kind: ContextKind; id: string } | null;  // → primaryConversation or a ContextItem in the active thread
  // getThreadsByView('recent'|'active'|'pinned') · selectThread · navigateToNext/Prev ·
  // setThreadStatus · pinThread/unpinThread · setPrimaryConversation ·
  // addContextItem/removeContextItem · setSelectedArtifact
  // merge rule: add = upsert by (kind,id); remove = unconditional (no immunity)
}
```

Relationship diagram:

```text
 AgentThreadsSlice
 ┌────────────────────────────────────────┐
 │ threadMap: Record<id, MessageThread> ──────contains 1:N──────────────┐
 │ activeThreadId ─────────FK──────────► (a threadMap key)              │
 │ selectedArtifact {kind,id} ──────ref──► (a ContextItem in active thr)│
 └────────────────────────────────────────┘                            ▼
                                                          MessageThread
                                                          ┌───────────────────────────────┐
                                                          │ id (PK)                       │
                                                          │ status · pinned · lastActiveAt│
                                                          │ inputContent                  │
                                                          │ messages   ──1:N──►  Message   │
                                                          │ chatContext ──1:1──▼ contains  │
                                                          └───────────────────────────────┘
                          ┌───────────────────────────────────┘                    │
                          ▼                                                         ▼ contains
                      Message                                                   ChatContext
                      ┌────────────────────────┐                                ┌────────────────────────────────────┐
                      │ id (PK) · role · content│                               │ summary                            │
                      │ openContext {kind,id} ──┼──ref──►(primary | an item)    │ primaryConversation {id,name} ─1:1─┼─► crm_conversations
                      └────────────────────────┘                                │ items  ──1:N──► ContextItem         │
                                                                                └────────────────────────────────────┘
                                                                                          │
                                                                                          ▼
                                                                                     ContextItem
                                                                                     ┌──────────────────────────────────┐
                                                                                     │ (kind, id)  ← composite key      │
                                                                                     │ id ──ref──► backend entity:      │
                                                                                     │   conversation → crm_conversations│
                                                                                     │   email_thread → crm_email_threads│
                                                                                     │   slack_thread → crm_slack_messages│
                                                                                     │   file         → documents        │
                                                                                     └──────────────────────────────────┘

 Persistence (client ⇄ server):
   MessageThread  ⇄  chat_threads   (id, name, color, context jsonb<ChatContext>)
   Message        ⇄  chat_messages  (id, chat_thread_id ──FK──► chat_threads.id, role, content, …)
   primaryConversation + items[]  live inside chat_threads.context (jsonB — no separate table)
```

## 4) Implementation phases

### Phase 1 — Thread status metadata (additive, no behavior change)

**Goal:** add the per-thread status fields and their setters without changing any UI.

- [x] Extend `MessageThread` in [MessageTypes.ts:248](apps/mail/modules/cedar-os/src/store/messages/MessageTypes.ts) with `status?: 'idle'|'streaming'|'finished'`, `pinned?: boolean`, `lastActiveAt?: string`, `createdThisSession?: boolean`. **Also added `inputContent?: string` here** (used in Phase 7) — pure additive type field, no behavior.
- [x] Add `setThreadStatus(threadId, status)` to `MessagesSlice` ([messagesSlice.ts](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts)) — sets `status`, bumps `lastActiveAt` when `'streaming'`. No-op (returns state unchanged) for an unknown threadId.
- [x] Add `pinThread(id)` / `unpinThread(id)` to `MessagesSlice`.
- [x] Set `createdThisSession: true` in `createThread` ([messagesSlice.ts:384](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts)).

**Tests:**

- [x] jest: `setThreadStatus` writes status + bumps `lastActiveAt`; `pinThread`/`unpinThread` toggle `pinned`, in `apps/mail/tests/modules/chat-store/threadStatus.test.tsx`.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/threadStatus` → 5 passed.

### Phase 2 — Derived views + selection/navigation

**Goal:** first-class `getThreadsByView` + selection/nav over `threadMap`.

- [x] Add `getThreadsByView('recent'|'active'|'pinned')` to `MessagesSlice` (pure selector: recent = non-placeholder sorted by `lastActiveAt ?? updatedAt` desc; active = `status==='streaming'`; pinned = `pinned===true` in threadMap insertion order).
- [x] Add `activeThreadId` as the canonical pointer; add `selectThread(id)` (updates both). **Divergence:** `activeThreadId` is a *synced duplicate* stored field (kept in lock-step in `setMainThreadId`/`switchThread`/`selectThread`), not a getter alias — zustand+immer `set()` does not preserve accessor properties across state replacement. `mainThreadId` stays the primary read until the Phase 10 rename collapses the two.
- [x] Add `navigateToNextThread()` / `navigateToPreviousThread()` operating over `getThreadsByView('recent')` (clamped, no wrap).

**Tests:**

- [x] jest: view filtering/ordering (recent order, active filter, pinned filter) + next/prev navigation, in `apps/mail/tests/modules/chat-store/threadViews.test.tsx`.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/threadViews` → 7 passed. (Two pre-existing failures in `messagesSlice.basic`/`.thread` confirmed present at baseline `c98daadd9`, unrelated to this work.)

### Phase 3 — Drive status from the connection slice

**Goal:** streams set thread status; `processingThreadIds` becomes a derived mirror.

- [x] In `agentConnectionSlice` call `setThreadStatus(id,'streaming')` at [agentConnectionSlice.ts:550](apps/mail/modules/cedar-os/src/store/agentConnection/agentConnectionSlice.ts) and `setThreadStatus(id,'finished')` at [agentConnectionSlice.ts:822](apps/mail/modules/cedar-os/src/store/agentConnection/agentConnectionSlice.ts) (alongside the existing add/removeProcessingThread for now).
- [x] Make `isThreadProcessing(id)` derive from `threadMap[id].status === 'streaming'`. This keeps the other `processingThreadIds`/`isThreadProcessing` consumers working unchanged: `messageQueueSlice` per-thread send gate ([messageQueueSlice.ts:121](apps/mail/modules/cedar-os/src/store/messageQueue/messageQueueSlice.ts)), `useCedarEditor` busy gate ([useCedarEditor.ts:335](apps/mail/modules/cedar-os/src/components/chatInput/useCedarEditor.ts)), and the task/agenda "working" indicators in [task-item.tsx:317](apps/mail/modules/userTasks/components/sections/task-item.tsx) + [AgendaTaskNode.tsx:171](apps/mail/modules/agentCanvas/extensions/AgendaTaskNode.tsx). **Follow-on fix:** `messageQueueSlice.test.tsx` simulated "processing" via `addProcessingThread` alone — updated it to mark `status:'streaming'` on a real thread entry and to reset `threadMap` in `beforeEach` (status now lives there, so it must be cleared between cases).

**Tests:**

- [x] jest: after a simulated send/finish cycle the thread's `status` transitions `streaming → finished`, and `getThreadsByView('active')` reflects it, in `apps/mail/tests/modules/chat-store/threadStatusLifecycle.test.tsx`.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/threadStatusLifecycle` → 2 passed; `jest messageQueue` → 10 passed (regression fixed).

### Phase 4 — Repoint EmbeddedCedarChat tab bar onto views

**Goal:** the tab bar reads views + pin/unpin instead of uxSlice tab arrays.

- [x] Replace `openChatTabIds` reads with `getThreadsByView('pinned')` (unioned with `mainThreadId`) in EmbeddedCedarChat — `openTabIds` is now a `useMemo` over `pinnedThreadIds ∪ active`.
- [x] Replace `addOpenChatTab`/`removeOpenChatTab`/`prependOpenChatTab` calls (seed/keep-visible/background/close) with `pinThread`/`unpinThread`; deleted the processing→finished tracking effect + `prevProcessingRef` (status is now set by the connection slice). Also repointed `AgendaTaskNode.tsx:390` (`addOpenChatTab` → `pinThread`) so the agenda-launch path keeps surfacing its thread as a tab. **Divergence:** tab order is now pin/insertion order (background-agent tabs no longer force-prepend) — acceptable per the view model; `switchThread` retained for actual thread switches (it loads messages; `selectThread`/`setMainThreadId` does not).
- [x] Replace per-tab `isTabProcessing`/`isTabFinished` with `thread.status` (`=== 'streaming'` / `=== 'finished' && tid !== mainThreadId`); `onValueChange` clears the finished badge via `setThreadStatus(tid,'idle')`.

**Tests:**

- [x] jest: the store-derived tab model (pinned view ∪ active, no active-dup, status→processing/finished with finished hidden on the active tab, close = unpin), in `apps/mail/tests/modules/chat-store/embeddedTabs.test.tsx`. **Divergence:** asserts the exact derivation the component performs against the real store rather than a DOM render — the full 1300-line `EmbeddedCedarChat` needs the entire tRPC/canvas/portal provider stack to mount, which is brittle and out of scope for a headless logic proof.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/embeddedTabs` → 5 passed. Touched source files typecheck clean (the only errors on touched files are `AgendaTaskNode.tsx:273,447`, confirmed pre-existing via `git blame`).

### Phase 5 — Repoint HomeChatSidebar + background badge onto views

**Goal:** the sidebar and finished badge consume the shared views.

- [x] `HomeChatSidebar` Recent reads `getThreadsByView('recent')` from the store, replacing its local `updatedAt` sort. Hydration: a new **`hydrateThreadsFromServer(threads)`** store action upserts the `chat.getThreads` payload into `threadMap` (preserving in-memory messages/status/pins, refreshing name/color/context/updatedAt) — the sidebar calls it in an effect on the query data, then renders the canonical view. Mirrors the existing init-time `syncThreadsFromStorage` upsert.
- [x] Background-agent finished badge derives from `thread.status === 'finished'` — **already landed in Phase 4** (the `finishedChatTabIds` reads + tracking effect were removed from EmbeddedCedarChat there; grep-verified none remain).

**Tests:**

- [x] jest: `HomeChatSidebar` Recent renders threads in `getThreadsByView('recent')` order + hydrates the store, in `apps/mail/tests/modules/home/HomeChatSidebar.test.tsx` (extended). Plus a store-level unit test of `hydrateThreadsFromServer` (upsert preserves messages/status/pins; hydrated threads surface in the recent view) in `apps/mail/tests/modules/chat-store/hydrateThreads.test.tsx`.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/home/HomeChatSidebar tests/modules/chat-store/hydrateThreads` → 6 passed. Touched source typechecks clean.

### Phase 6 — Remove uxSlice chat tabs + processingThreadIds

**Goal:** delete the now-dead ad-hoc state; finalize the active-pointer rename.

- [x] Delete `openChatTabIds` / `finishedChatTabIds` + their six actions from [uxSlice.ts](apps/mail/modules/ux/uxSlice.ts) (state decls + implementations).
- [x] Point `selectChatThreadId` in [uxSlice.ts](apps/mail/modules/ux/uxSlice.ts) to `activeThreadId` (falling back to `mainThreadId` during the transitional overlap).
- [x] Repoint every direct `state.processingThreadIds.has(...)` reader onto `isThreadProcessing(id)`: `task-item.tsx:317`, `AgendaTaskNode.tsx:171`, `ChatBubbles.tsx:28`. (`EmbeddedCedarChat`'s reads were already removed in Phase 4; `messageQueueSlice`/`useCedarEditor` already go through `isThreadProcessing`, now status-derived.)
- [x] Remove `processingThreadIds` (the Set) + `addProcessingThread`/`removeProcessingThread` from `MessagesSlice`; drop the calls in `agentConnectionSlice` (status set alongside already covers it). **Divergence:** the global `isProcessing` boolean + `setIsProcessing` are **kept** — they have live non-tab readers (`useMessages` → CaptionMessages/CommandBar/BottomCommandBar, `voiceSlice`, `ChatBubbles`). They're now a plain decoupled flag: `setThreadStatus`/`setMainThreadId`/`switchThread` keep `isProcessing` in sync with the *active* thread's status, and `setIsProcessing` is a direct setter. The doc's "remove once no reader remains" precondition isn't met, so removing them was out of scope.
- [x] Grep-verify no remaining `openChatTabIds` / `finishedChatTabIds` / `processingThreadIds` references (only descriptive comments in `MessageTypes.ts` remain).

**Tests:**

- [x] jest: store suites green (`__tests__/store`, `tests/modules/chat-store`, `tests/modules/home`) — 155 passed. (3 pre-existing failures: `messagesSlice.basic`/`.thread` — confirmed at baseline `c98daadd9`; `agentContextSlice.test` "failed to run" on a `createAgentContextSlice` mock issue — confirmed at Phase-5 commit `04fb371f8`. None caused by this phase.)
- [x] `pnpm --filter @zero/mail exec jest __tests__/store tests/modules/chat-store tests/modules/home`; touched source typechecks clean (remaining tsc errors on `EmbeddedCedarChat`/`task-item` are on untouched lines from April commits, confirmed via `git blame`).

### Phase 7 — Per-thread input content

**Goal:** each thread owns its draft input; switching threads preserves it.

- [x] Add `inputContent?: string` to `MessageThread` (landed in Phase 1).
- [x] `setChatInputContent` now writes the active thread's `inputContent` alongside the mirror; the editor read stays via `chatInputContent`. **Divergence:** the global `chatInputContent` field/setter are **kept as an active-thread mirror**, not removed — they have ~18 call sites (task/agent/config prompt injectors) plus a reactive editor content binding. Deleting the field would ripple across all of them and break the editor's reactive read; the per-thread persistence (the actual feature) is delivered by backing the mirror with `threadMap[activeThreadId].inputContent`. Removal is deferred to Phase 10's agentContext fold.
- [x] On `setMainThreadId`/`switchThread`, restore `chatInputContent` from the target thread's `inputContent`; `useCedarEditor` hydrates the composer via a new effect keyed on `activeThreadId` (runs on switch, not keystrokes, so it never clobbers active typing).

**Tests:**

- [x] jest: typing in thread A, switching to B, back to A restores A's draft (and B's stays independent), in `apps/mail/tests/modules/chat-store/perThreadInput.test.tsx`.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/perThreadInput` → 2 passed; `ProposalsReviewPanel` (a `setChatInputContent` consumer) still green. Touched files typecheck clean.

### Phase 8 — Mentions → explicit context items (two triggers, delete the mention channel)

**Goal:** an `@`/`[[` mention is an explicit attach that adds a `ContextItem`; one context system, no registry.

- [x] A mention now carries a hard-typed `kind`: the `MentionProvider` gained a `contextKind` field, and `useConversationMentionProvider` sets `contextKind: 'conversation'`. **`[[` → file is implemented** (`fileMentionSuggestion.ts`, wired into `useCedarEditor`): it reuses the document editor's `files.searchForLink` + `FileLinkMenu`, and on select commits a `file` `ContextItem` and inserts a mention node stamped `{ kind:'file', id }` (rendering via the same MentionChip). **Divergence:** only the *extra* `@` kinds (`email_thread`/`slack_thread`) and full deletion of the `mentionProviders` registry remain deferred — those kinds have no client-side mention *search* tRPC yet (parallels chat-context-set's conversation-only `resolve-and-attach`). The registry is kept as the (now kind-tagged) `@`-conversation search layer.
- [x] `mentionSuggestion.command` now calls `addContextItem(activeThreadId, { kind, id, label, addedBy: 'user' })` (explicit, durable, optimistic-pinned) instead of `addContextEntry`; the inserted mention node is stamped with `{ kind, id }` (new `kind` node attribute registered in `useCedarEditor`).
- [x] Committed mentions render as chips via the existing **`ChatContextRow`** (already iterates `chatContext.items`, already rendered in `EmbeddedCedarChat`) — so no `ContextBadgeRow` rewrite was needed for visibility. **Divergence:** `ContextBadgeRow` (the `additionalContext` badge row in `ChatInput`/`CommandBar`) is left intact for its non-mention sources (ReportsView `openDocument`/`intelligence_workspace`); `addContextEntry`/`registerMentionProvider`/`getMentionProvidersByTrigger` are retained (still used by search + ReportsView), and `useSubscribeStateToAgentContext` removal is deferred (dead but only reachable from its own tests; removing it is a self-contained follow-up).

**Tests:**

- [x] jest: `mentionSuggestion.command` commits a `conversation` `ContextItem` to the active thread and stamps `{ kind, id }` on the inserted node (editor stub + real store), in `apps/mail/tests/modules/chat-store/mentionToItem.test.tsx`.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/mentionToItem` → 1 passed (full chat-store suite: 24 passed). Touched files add no new tsc errors (the one flagged line is an unchanged Oct-2025 `import` — pre-existing).

### Phase 9 — Unique mention rendering in the transcript

**Goal:** mentions render as clickable chips (opening the artifact), not plain text.

- [x] `renderMarkdown` in [useCedarEditor.ts](apps/mail/modules/cedar-os/src/components/chatInput/useCedarEditor.ts) now serializes a mention node with a `kind`+`id` to the structured markdown token `[@Label](cedar-item:<kind>:<id>)` (falling back to `@label` when unstamped).
- [x] New shared `MentionChip` component (`apps/mail/modules/cedar-os/src/cedar-os-components/chatMessages/MentionChip.tsx`) + `parseCedarItemToken`. `MarkdownRenderer`'s `a` component routes any `href` starting with `cedar-item:` to `MentionChip`; clicking (or Enter/Space) → `setSelectedArtifact({ kind, id })`. **No new remark plugin needed** — the token is a plain markdown link, so it flows through the existing `a` renderer.
- [x] Composer badge row reuse: the committed-item chips already render via `ChatContextRow` (Phase 8). **Divergence:** `MentionChip` is a distinct transcript chip (click → open artifact) from `ChatContextRow`'s composer chips (which also carry a remove ✕); they share the click-to-`setSelectedArtifact` behavior but not the component, since the composer chip needs removal affordances the transcript chip must not have.

**Tests:**

- [x] jest/component: `MentionChip` renders its label and sets `selectedArtifact` on click; `parseCedarItemToken` handles well-formed tokens, colon-containing ids, and rejects malformed input, in `apps/mail/tests/modules/chat-store/mentionChip.test.tsx`.
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/mentionChip` → 5 passed. Touched files typecheck clean.

### Phase 10 — Rename MessagesSlice → agentThreadsSlice + fold agentContext

**Goal:** threads are the first-class entity; per-thread input/context live on the store.

- [x] Rename `MessagesSlice`/`createMessagesSlice` → `AgentThreadsSlice`/`createAgentThreadsSlice`; updated the store composition (`createCedarStore.ts`, `modules/store/index.ts`), the `CedarStore` type intersection (`CedarStoreTypes.ts`), and the `cedar-os/src/index.ts` re-export + test describe strings. **Divergence:** the *file* keeps its `messagesSlice.ts` name (renaming it would churn ~dozen import paths for no functional gain); only the exported symbols were renamed.
- [x] Per-thread state now lives on the thread (status/pin/lastActiveAt/inputContent/context set from earlier phases). **Divergence:** the global `chatInputContent` mirror is **retained** (Phase 7 rationale — reactive editor binding + ~18 call sites); `mentionProviders`/`additionalContext` are also retained (Phase 8 rationale — search layer + ReportsView). These deletions are deferred, not done here.
- [x] Grep-verify: no `MessagesSlice`/`createMessagesSlice` code symbols remain (only a descriptive "formerly named" comment). Runtime store composes and all store/chat-store tests pass.

**Tests:**

- [x] store + chat-store jest suites green (152 passed; the same 3 pre-existing failures — `messagesSlice.basic`/`.thread`, `agentContextSlice` suite-load — unchanged by the rename).
- [x] `pnpm --filter @zero/mail exec jest __tests__/store tests/modules/chat-store`; touched files add no new tsc errors (the `createCedarStore.ts` TS2740/TS2590 store-composition errors are pre-existing — confirmed identical at Phase-9 commit `e85545f1d` via isolated worktree).

### Phase 11 — Composer `/` slash menu (thread commands)

**Goal:** a `/` command menu in the chat input with `new` / `fork` / `clear`.

- [x] Added a `/` Suggestion extension to the chat editor (`slashCommandSuggestion.ts` + `SlashCommandMenu.tsx`, mirroring `FileLinkSuggestion.ts` with a distinct `PluginKey` and `startOfLine: true` so mid-sentence `/` is safe), wired into `useCedarEditor`'s extension list.
- [x] Wired the three thread commands (`slashCommands.ts`): `/new` → `createThread()` + `selectThread`; `/fork` → new `forkThread(activeThreadId)` store action (deep-copies `messages` + `chatContext.items`, names it `"<name> (fork)"`, selects it); `/clear` → `clearMessages(activeThreadId)` (keeps the thread + `chatContext`).
- [x] Each command's `run` handler first `deleteRange`s the typed `/cmd` text, so it's never included in the sent message.

**Tests:**

- [x] jest: `/new` creates+selects a fresh thread; `/fork` deep-copies `messages` + `chatContext` into a new selected thread; `/clear` empties the active thread's messages while preserving `chatContext`; `filterSlashCommands` narrows by id/label — in `apps/mail/tests/modules/chat-store/slashCommands.test.tsx` (editor stub + real store).
- [x] `pnpm --filter @zero/mail exec jest tests/modules/chat-store/slashCommands` → 4 passed (full chat-store suite: 33 passed). New files typecheck clean.