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.