diff --git a/docs/design/UNIFIED_CHAT_UX.md b/docs/design/UNIFIED_CHAT_UX.md new file mode 100644 index 00000000..df536174 --- /dev/null +++ b/docs/design/UNIFIED_CHAT_UX.md @@ -0,0 +1,223 @@ + + +# Unified Chat UX — Design Brief + +> Status: **brief**. Sets scope for the visual design layer that pairs with the FE-710 substrate work. Companion to `CONVERSATIONAL_WORKSPACE_RUNTIME.md` (substrate) the way `SIDE_CHAT.md` paired with `MULTI_CHAT.md`. Not a UX spec yet — the spec emerges from the Ladle prototype this brief unblocks. +> +> Authority boundary: structural primitives (thread kinds, target attachment, kickoff, lifecycle, mention substrate) are governed by `CONVERSATIONAL_WORKSPACE_RUNTIME.md` and `memory/SPEC.md` (Req 45, D153 / D154, I111). Library/stack labels (`ai-elements`, `useChat`, `streamdown`, `motion`, `lucide-react`) are inferred from the existing interview surface and may not be re-decided here. + +## 1. Purpose + +Design the visual and interaction layer for **threads** rendered inline in the unified chat surface. Threads are durable kickoff-anchored sub-runs of five kinds (`interview` / `side` / `reconciliation` / `qa` / `agent_run`). The interview thread is the spine; other threads render inline as collapsibles invoked from a turn. + +## 2. Modes (Shift+Tab) + +The chat composer carries a **mode chip**. **Shift+Tab** toggles between two user modes. The mode at submit time determines which `thread.kind` is created. + +| Mode (visible label) | `thread.kind` | When | +| --- | --- | --- | +| **Ask** | `qa` | Open-ended question scoped to mentioned items; assistant is read-only (no `propose_*` tools). | +| **Edit** | `side` | Refine, tighten, or annotate a specific item; assistant can `propose_edit` / `propose_annotate` / `propose_drill_down` / `propose_edge`; cascades resolve in-thread. | +| *(batch-surfaced, no user mode)* | `reconciliation` | Reaches users through **"Reconcile Now"** (§7 dec 10) or auto-surfacing when needs accumulate. Not a composer mode — users don't *author* a reconcile, the system *surfaces* one. | +| *(assistant-spawned, no user mode)* | `agent_run` | Spawned by the assistant from inside any other thread; rendered inline via `thread.invoked_in_turn_id`. | +| *(implicit)* | `interview` | The chat's spine; not user-selectable. | + +**Persistence:** the mode at submit time is the thread's kind **forever**. Reopening a thread shows the kind chip; switching modes mid-thread is not allowed — open a new thread instead. + +**Implementation note:** the existing code uses internal mode state values `explore` (= Ask) and `edit` (= Edit) per slice 8 (`401f4037`). The composer label and `` text use the brief's *Ask* / *Edit* register; the internal state name can stay `explore/edit` or rename to `ask/edit` in a later refactor. + +**Suggestions on turn-zero:** fresh threads show a `` row (ai-elements) with 3 mode-appropriate prompts. Replaced by a normal composer once the user types. + +**Suggestion source:** **static per mode** in V1 (hard-coded prompt lists keyed by mode + thread-kind context). LLM-generated, context-aware suggestions are a future improvement. + +## 3. Mention vocabulary + +| Symbol | Resolves to | Behavior | +| --- | --- | --- | +| `#` | **Knowledge items** — typed intent items (goal / term / context / constraint / decision / assumption / requirement / criterion / invariant / example). | Chip shows the item's **reference code** (e.g. `#A12`, `#CTX13`, `#GOAL3`); kind is read from the prefix; chip tint comes from `kindAccentHex` (already used by `knowledge-card.tsx`). Adds a durable `thread_context_item` row (Track 5); revocable. | +| `$` | **Threads** (current spec's open / closed threads). | Chip linking to the thread; click jumps and expands it inline. | +| `!` | **Annotations and other (untyped) artifacts** — anything in the workspace that isn't a typed intent item with a reference code (durable `annotation` rows, free-text artifacts, in-view selections). | Chip shows a short label; resolves at type-time against workspace state; persists into the turn as a snapshot reference. | +| `@` | **Reserved — later for code references** (files / symbols / locations in the workspace's source). | Not wired in V1; do not surface autocomplete on `@` until the code-reference use is implemented. | +| `-` | **Omitted.** | Not used as a mention symbol — too overloaded in plain text. | + +Autocomplete pops on `#`, `$`, or `!` press via Radix `Combobox` / `cmdk` (already a dep). `#` autocomplete reads the spec's intent graph (with refcodes + kind tints); `$` reads the spec's threads; `!` reads annotations + currently-visible workspace artifacts. Mentions are durable mutations — they outlive the turn that authored them. + +## 4. Layout presentations + +Four states, user-toggleable from a header control on the chat. State persists per workspace (localStorage). **Default: Side-docked** — matches today's two-pane workspace shell. + +| State | Footprint | Use | +| --- | --- | --- | +| **Compact** | small floating dock, ~360–420 px wide | quick check; minimal surface; suggestions condensed or hidden | +| **Side-docked** *(default)* | right rail, ~50% width | Notion-style; two-task — spec on left, chat on right | +| **Maximize** | wide center, ~70% with rails | Linear-style; chat focus, spec view still visible | +| **Full** | 100% workspace | chat-only; spec recedes; deep dialog or agent-run inspection | + +State transitions animate via `motion`. The mode chip and the layout-state control share the chat's header strip. + +## 5. Canonical scenes + +Each becomes one Ladle story in the prototype. + +| # | Scene | What it shows | +| --- | --- | --- | +| 1 | **Reference — side-docked** | Spine + collapsed side thread + open reconciliation thread + collapsed agent run. The hero. | +| 2 | **Mode toggle in composer** | Mode chip toggles Ask ↔ Edit via Shift+Tab; suggestions row updates per mode. | +| 3 | **Side thread — first open** | `Edit` submit on `''` with impact > soft; kickoff card + suggestions visible. | +| 4 | **Reconciliation thread — batch surfaced** | Target-grouped, topo-sorted upstream-first; classifier states visible (auto-edit one-click apply chip, substantive judgment affordance); auto-confirm rows never visible. | +| 5 | **QA thread — with mentions** | User-initiated; one `#A12` chip (knowledge item, refcode-prefixed + kind-tinted), one `!selection` chip (workspace artifact), one `$thread` chip (linking another thread); mention autocomplete shown in a parallel state. | +| 6 | **Agent-run — inline collapsible** | `` components nested; progress narration *Reviewing… / Building… / Generating…* with timer; collapsed-by-default once complete. | +| 7 | **Subtle surfacing — structured-list** | Knowledge items with open-thread chips per kind (trailing badge `◉ 2`). | +| 8 | **Subtle surfacing — graph view** | Same chips, graph projection. | +| 9 | **Layout — compact** | Small floating dock with one open thread. | +| 10 | **Layout — full** | Chat at 100% workspace; spine + collapsibles; no spec rail. | + +## 6. Kickoff copy + +Simple, declarative, second-person where conversational. Modeled on the existing Figma register (*"Ask me everything…"*, *"Now generating the new questions…"*). One default per kind; alternates iterate in the prototype. + +### Side (Edit) + +- **Kickoff:** "Editing **''**. **** related items may need updating." +- **Suggestions:** *Refine the wording* · *Tighten the constraint* · *Add a counterexample* + +### Reconciliation (batch-surfaced or "Reconcile Now") + +- **Kickoff:** "**** reconciliations on **''**. **** auto-edit, **** need review." +- **Suggestions:** *Apply auto-edits* · *Show only substantive* · *Skip for now* +- *Note:* not a user composer mode; the thread is surfaced when the classifier accumulates needs against a target, or when the user clicks **Reconcile Now** (§7 dec 10). + +### QA (Ask) + +- **Kickoff:** "Anchored to ****. Ask anything." +- **Composer placeholder:** *"Ask me everything…"* +- **Suggestions:** *What's the goal?* · *Show related decisions* · *Where's the friction?* + +### Agent run (assistant-spawned) + +- **Kickoff:** "**** …" — e.g. *Summarizing what's open across all phases…* +- **Progress steps:** *Reviewing the prompt* · *Building the plan* · *Generating clarifying questions* (verb-first task narration with timer) + +### Interview spine + +Unchanged — inherits existing phase-entry kickoff turns; not redesigned here. + +## 7. Visual decisions (recommendations) + +► = recommended; revise in the prototype. + +1. **Spine reflow** (not overlay) when a thread expands. ► +2. **Collapsed thread row:** kind chip + target/title + turn count + relative time. ► +3. **No per-kind background tint.** Icon + neutral chrome; subtle accent only on the kind chip. ► +4. **Sticky in-thread header** when expanded body exceeds viewport: kind chip + target link + lifecycle status + close. ► +5. **Animation curve:** `motion` spring, soft (mass 0.6, stiffness 220, damping 30), ~250 ms. ► +6. **Item-anchored badge** in structured-list / graph views: trailing, persistent, with count; hover reveals kind breakdown; click jumps to the thread. ► +7. **Multiple open threads on one item:** sibling collapsibles in stream order; partial unique indexes bound to one open per (kind, target). ► +8. **Close behavior:** explicit close for `side` / `qa`; auto-close on resolution for `reconciliation` / `agent_run` with a brief "done" affordance. ► +9. **Mention chip behavior:** `#` (knowledge item) chips jump to the item in structured-list / graph view, kind shown by refcode prefix + `kindAccentHex` tint; `$` (thread) chips jump and expand inline; `!` (annotation / artifact) chips show the snapshot reference inline. All revocable via dropdown. ► +10. **"Reconcile Now" placement:** sidebar with count badge, near readiness / turn-count metadata. Not top bar, not in-stream banner. ► + +## 8. Motion + chip vocabulary + +- **Motion library:** `motion` (Framer Motion). +- **Expand/collapse:** spring per Decision 5; reflows surrounding stream. +- **Streaming live state:** kickoff card shows pulsing "generating…" with timer (mirror *"Now generating the new questions…"*). Reuse `Reasoning` live-state pattern. +- **Chips:** kind chip = `lucide-react` icon + label. Icon family locked to `lucide-react`; no custom set. + +| `thread.kind` | `lucide-react` icon | Notes | +| --- | --- | --- | +| `interview` | — (no chip; it's the spine) | | +| `side` | `PencilLine` | Edit/refine register | +| `reconciliation` | `GitMerge` or `RefreshCw` | Cascade-cleanup register | +| `qa` | `MessageCircleQuestion` | Open-ended question | +| `agent_run` | `Sparkles` or `Workflow` | Assistant-driven task | + +Accent: kind chip carries one subtle color (~8–12% tint of the kind's accent on a white chip background). No competing palettes in stream. + +## 9. Color, type, density + +Inherit from the interview surface + `kindAccentHex` (`src/client/components/knowledge-card.tsx`). Concretely: + +- **Base:** `#ffffff` page, `#fafafa` rail / panel tint, `#e3e3e3` hairlines. +- **Text:** `#202020` / `#5b5b5b` / `#a6a6a6` (primary / secondary / tertiary). +- **Inter** everywhere; Gotham reserved for the HASH wordmark. +- **Radii:** 6 (chip) / 8–12 (card) / 16 (overlay). +- **Shadow stack** (cards, composer, dock): `0 4 4 -2 rgba(0,0,0,0.02), 0 2 2 -1 rgba(0,0,0,0.02), 0 0 0 1 rgba(0,0,0,0.08)`. +- **Density:** Inter Medium / 13–14 px / line-height 1.6. + +## 10. Accessibility + +Non-negotiable in this layer (dark mode deferred). + +- **Keyboard:** + - **Shift+Tab** cycles modes (preserves browser tab behavior outside the composer). + - **⌘/Ctrl+Enter** submits. + - **Esc** collapses an open thread (or steps the layout state down by one tier). + - **↑/↓** within the suggestions row. +- **Focus management:** on thread creation, autofocus the new thread's composer; on expand, focus the kickoff card; on collapse, return focus to the invoking turn. +- **ARIA:** `role="region"` on thread collapsibles with `aria-label` = kind + target; `aria-expanded` on the toggle. +- **Live regions:** streaming progress narration uses `aria-live="polite"`. +- **Color is never the sole carrier** of kind information — icons + labels (and refcode prefix for `#`) accompany every chip. + +## 11. Generative / typed UI parts + +The chat continues to use **typed data parts** via `BrunchUIMessage` / `brunchDataPartSchemas`. Threads compose around them; the **review-set surface** (requirements, criteria) keeps its current component vocabulary and renders as a typed data part inside the interview thread, not absorbed into a thread-generic shell. + +New typed parts likely needed (substrate-allowing): `thread.kickoff`, `thread.suggestions`, `thread.mention_resolved`, `thread.reconciliation_summary`, `thread.agent_progress`. Schemas land alongside the build slices that introduce each thread kind. + +## 12. Constraints & non-goals + +### Constraints (inherited; not negotiable here) + +- Compose above `ai-elements/*` (vendored); vendor additional ai-elements (e.g. `Reasoning`, `Suggestions`, `Sources`) rather than fork. +- Each active thread mounts its own `useChat` (working assumption per HANDOFF; confirm at S2). +- Layout shells unchanged: `AppLayout` / `SpecificationWorkspaceLayout` / `ViewLayout` (SPEC §Layout Architecture). +- Existing routed interview surface preserves SPEC I24. +- **Suggestion content must respect relation-policy validity** (SPEC D137 / I118). No suggestion may propose an edge, item, or action that violates relation directionality or kind constraints. Applies to static-per-mode V1 and any future LLM-generated variant. + +### Non-goals + +- Dark mode — explicitly deferred. +- Per-thread background tints / brand gradients / glow rings. +- Spatial canvas graph view — deferred per PLAN horizon. +- SideChatPopover persistence (V4a) — superseded by threads. +- Strategy chats as separate routes — strategies are thread-local (D148). +- `@` (future code-references) and `-` mention behavior — reserved / omitted in V1. +- TOON serializer — owned by Track 5 (`thread-context-provision`). +- Reconciliation classifier scheduling — owned by Track 3 (`reconciliation-runtime`). +- Mode-switching mid-thread — not allowed; open a new thread instead. + +## 13. Next step — Ladle prototype + +The prototype lives at `.ladle/` (existing harness, `npm run ladle`). One story per canonical scene from §5, composed from `ai-elements/*` + new `src/client/components/threads/*` shells. The prototype confirms or revises every §7 / §8 / §10 decision in code; this brief is the starting frame, not the verdict. + +Deliverable: a Ladle build that renders all ten canonical scenes from §5 with mock data and the recommended decisions. Iterate visually; promote stabilized components into S2/S3 of FE-710 when the substrate-landing slice merges. + +### Build order + +Four phases; each one ends in a reviewable commit on the FE-710 branch. + +- **Phase A** — Scene 1 (reference side-docked hero) + Scene 4 (reconciliation thread). Covers multi-kind rendering and the most distinct thread shape in one go. +- **Phase B** — Scene 2 (mode toggle) + Scene 3 (side first-open) + Scene 5 (QA with mentions). Interactive composer + mention-chip work. +- **Phase C** — Scene 6 (agent run) + Scenes 7 / 8 (structured-list & graph view chips). +- **Phase D** — Scene 9 (compact) + Scene 10 (full). Layout-state coverage. + +## 14. Prototype-settle questions + +Decisions the Ladle prototype will resolve in code; not blocking the brief. + +- **Compact-state composer affordances** — in the smallest layout, suggestions probably can't fit. Cut to one suggestion? Hide entirely until input has focus? +- **Mode chip placement** — leading edge of composer (with the icon) vs trailing edge (next to send)? +- **Per-kind icon family iteration** — the table in §8 is a first pass; iterate against the rest of the app's icon usage for cohesion. +- **Progress-step narration** — server-streamed verb-list requires routes to emit named steps. Worth wiring as a typed data part (`thread.agent_progress`) so UI is purely declarative. diff --git a/drizzle/0020_chat_secondary_chat_columns.sql b/drizzle/0020_chat_secondary_chat_columns.sql new file mode 100644 index 00000000..02a84d82 --- /dev/null +++ b/drizzle/0020_chat_secondary_chat_columns.sql @@ -0,0 +1,6 @@ +ALTER TABLE `chat` ADD `parent_chat_id` integer REFERENCES `chat`(`id`);--> statement-breakpoint +ALTER TABLE `chat` ADD `invoked_in_turn_id` integer REFERENCES `turn`(`id`);--> statement-breakpoint +ALTER TABLE `chat` ADD `pinned_item_id` integer REFERENCES `knowledge_item`(`id`);--> statement-breakpoint +ALTER TABLE `chat` ADD `pinned_span_hint` text;--> statement-breakpoint +CREATE INDEX `chat_parent_chat_id_idx` ON `chat` (`parent_chat_id`);--> statement-breakpoint +CREATE INDEX `chat_invoked_in_turn_id_idx` ON `chat` (`invoked_in_turn_id`); diff --git a/drizzle/0021_chat_mode.sql b/drizzle/0021_chat_mode.sql new file mode 100644 index 00000000..bf73a244 --- /dev/null +++ b/drizzle/0021_chat_mode.sql @@ -0,0 +1 @@ +ALTER TABLE `chat` ADD `mode` text; diff --git a/drizzle/0022_chat_pinned_reconciliation_need.sql b/drizzle/0022_chat_pinned_reconciliation_need.sql new file mode 100644 index 00000000..2937e815 --- /dev/null +++ b/drizzle/0022_chat_pinned_reconciliation_need.sql @@ -0,0 +1 @@ +ALTER TABLE `chat` ADD `pinned_reconciliation_need_id` integer REFERENCES `reconciliation_need`(`id`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index dc58e5b2..ab3e085e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,27 @@ "when": 1776360000000, "tag": "0019_reconciliation_need_agent_columns", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1779033600000, + "tag": "0020_chat_secondary_chat_columns", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1779120000000, + "tag": "0021_chat_mode", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1779206400000, + "tag": "0022_chat_pinned_reconciliation_need", + "breakpoints": true } ] } \ No newline at end of file diff --git a/memory/CARDS.md b/memory/CARDS.md new file mode 100644 index 00000000..20999ac2 --- /dev/null +++ b/memory/CARDS.md @@ -0,0 +1,273 @@ + + +# Cards — `chat-runtime-secondary-chats` (FE-716) + +Branch: `ka/fe-716-chat-runtime-unified-secondary-chats` +Linear: [FE-716](https://linear.app/hash/issue/FE-716) +Stacked on: `ln/fe-709-reconciliations` (PR #139, awaiting merge to main) + +## V1 framing + +V1 = "every behavior the current side-chat (V3.1) ships today, surfaced through the elevated unified-workspace shape from `docs/design/UNIFIED_CHAT_UX.md`." Build only what that framing requires; defer the rest of the brief to follow-up frontiers. See PLAN.md `chat-runtime-secondary-chats` § V1 narrowing for the explicit defer list. + +Vocabulary: **secondary chat** (matches PR #139's lexicon). The `chat.parent_chat_id IS NOT NULL` projection is the sole driver of "render inline as a secondary chat under parent." + +## Card queue + +### C0 — Bring forward `UNIFIED_CHAT_UX.md` design brief + +- **Status:** **done** (2026-05-15) — Option B chosen (verbatim body + prepended `` translation header mapping `thread` → `secondary chat` and noting D153 substrate deferral). +- **What:** Copy `docs/design/UNIFIED_CHAT_UX.md` verbatim from PR #138 onto this branch. Body preserved unedited; reading-note header added for current readers. Brief stays the canonical UX ceiling for future tracks. +- **Why first:** Zero substrate dependency; gives downstream cards a single in-tree reference. Cheap to land alone. +- **Scope:** doc-only. +- **Verification:** `npm run check` — 0 errors (6 pre-existing warnings unrelated). Body matches PR #138 commit `cd48b49a` byte-for-byte. + +### C1 — Substrate migration: four columns on `chat`, zero enum changes + +- **Status:** **done** (2026-05-15) — `drizzle/0020_chat_secondary_chat_columns.sql` adds the four nullable integer/text columns + two non-unique indexes; `src/server/schema.ts` chat table promoted to `(table) => […])` form to declare the indexes. Real schema uses `integer` ids (HANDOFF's UUID was illustrative). Resolved: `invoked_in_turn_id` kept (denormalized anchor); `pinned_reconciliation_need_id` deferred; per-turn span-hint not in V1; `parent_chat_id` + `invoked_in_turn_id` indexed. +- **What:** Drizzle migration adding `parent_chat_id integer NULL REFERENCES chat(id)`, `invoked_in_turn_id integer NULL REFERENCES turn(id)`, `pinned_item_id integer NULL REFERENCES knowledge_item(id)`, `pinned_span_hint text NULL` + indexes `chat_parent_chat_id_idx` and `chat_invoked_in_turn_id_idx`. `chat.kind` enum unchanged; `chat.active_turn_id` preserved. +- **Verification:** `npm run verify` — 100 test files / 1272 tests pass; build clean. New tests in `src/server/chat-substrate.test.ts` cover column shape, index presence, FK integrity (parent_chat_id, pinned_item_id, invoked_in_turn_id all reject missing targets), nullable inserts, and `chat.active_turn_id` preservation. +- **Out of scope:** any new enum value; the `thread` table; `turn.thread_id`; `thread_context_item`. + +### C2 — Server: `createSecondaryChat` + `createKickoffTurn` helpers + +- **Status:** **done** (2026-05-15) — helpers + tests landed; route deferred to C3 to avoid speculative scaffolding (no consumer until UI wires up). +- **What:** Two new public DB helpers exported from [src/server/db.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/db.ts): + - `createSecondaryChat(db, specId, { parent_chat_id, invoked_in_turn_id?, pinned_item_id?, pinned_span_hint? })` — inserts a `chat` row with `kind='side_chat'` and the four C1 columns; returns `Chat`. + - `createKickoffTurn(db, chatId, { phase, content })` — inserts a `turn` with `turn_kind='kickoff'`, `chat_id=chatId`, and `assistant_parts=content`; resolves the chat's `specification_id` automatically; returns `Turn`. +- **Verification:** `npm run verify` — 100 test files / 1277 tests pass. New tests in [src/server/chat-substrate.test.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/chat-substrate.test.ts) cover happy-path persistence, optional column population, FK rejection, kickoff turn metadata, and error on missing chat. +- **Out of scope (moved to C3):** `POST /api/specifications/:id/secondary-chats` route. Building it without a consumer is speculative; C3 will define the route alongside the UI client that calls it. +- **Harvest reference:** `src/server/side-chat-route.ts`, `src/server/side-chat-prompt.ts`, PR #138's threads endpoint. + +### C3 — Client: `secondary-chat-collapsible` inline component + +C3 has been split into three sub-cards (C3a / C3b / C3c) for verifiable thin slices. Original "What" preserved below for reference. + +- **C3 original What:** Build the inline collapsible UI for `chat.parent_chat_id IS NOT NULL` chats, anchored under their `invoked_in_turn_id` in the parent transcript. Driven entirely by the projection rule — no flavor enum needed. Replace `SideChatHost`'s popover plumbing with inline rendering inside `ContinuousWorkspaceView`. +- **Out of scope (across all sub-cards):** popover deletion (C8), Ask/Edit toggle (C4), patch staging (C5), `#` injection (C6). + +#### C3a — Server: `listSecondaryChatsForSpecification` + bundle field + +- **Status:** **done** (2026-05-15) — list helper, `SecondaryChatWithKickoff` type, bundle `secondaryChats` field, and Zod schema all landed. +- **What:** New helper `listSecondaryChatsForSpecification(db, specId) → SecondaryChatWithKickoff[]` returns secondary chats (rows with `parent_chat_id IS NOT NULL`) with each chat's first kickoff turn (or null). `readSpecificationStateProjection` includes the projected `secondaryChats` field; `specificationStateSchema` extended with `secondaryChatStateSchema`. +- **Verification:** `npm run verify` — 100 test files / 1283 tests pass. New tests cover empty/single/multi-spec scoping, kickoff turn population, missing-kickoff null fallback, primary-chat exclusion, and bundle inclusion via `getSpecificationState`. + +#### C3b — `` standalone component + +- **Status:** **done** (2026-05-15) — component + tests landed; mounting deferred to C3c (where there's a real consumer to drive it). +- **What:** New `src/client/components/secondary-chat-collapsible.tsx` renders a Radix-`Collapsible`-backed secondary chat surface. Header always renders; body shows the kickoff turn's `assistant_parts` and is collapsed by default. Supports `kickoffTurn=null` (renders an empty body when expanded). +- **Verification:** `npm run verify` — 101 test files / 1287 tests pass. New tests in `src/client/components/__tests__/secondary-chat-collapsible.test.tsx` cover header presence, collapsed-by-default, expand-on-click reveals content, and empty-body fallback for missing kickoff. +- **Scope adjustment from original C3b:** mounting in `-continuous-workspace-view.tsx` deferred to C3c. Reason: `WorkspaceTranscriptArtifacts` (556 LOC) is the actual turn-render seam; threading the collapsible through it is invasive enough to merit landing alongside the trigger that creates the rows in the first place. Building mounting now without a creation flow would require fixture-seeding side-channels. + +#### C3c-route — Server: `POST /api/specifications/:id/secondary-chats` + +- **Status:** **done** (2026-05-15) — route + handler landed; client wiring + view mounting deferred to C3c-mount and C3c-wire. +- **What:** New `src/server/secondary-chat-route.ts` exports `handleCreateSecondaryChatRequest(db, req, res)`. Body schema: `{ parentChatId, invokedInTurnId, itemKind, itemId, spanHint? }`. Validates spec exists, validates body shape, resolves the item via `getKnowledgeItem` (rejects if missing or wrong kind/spec), calls `createSecondaryChat` + `createKickoffTurn`, returns `{ chatId, kickoffTurnId }`. Kickoff content templated as `Anchored to ''.` (with `, focused on ''` when provided) — minimal V1 wording; richer per-mode templates from UNIFIED_CHAT_UX.md §6 land alongside C4 (Ask/Edit toggle). +- **Verification:** `npm run verify` — 101 test files / 1292 tests pass. New tests in `src/server/app.test.ts` cover happy path with bundle round-trip, span-hint persistence, 400 on bad body, 404 on missing spec, and 404 on missing item. + +#### C3c-mount — View: thread `secondaryChats` through to `` mounting + +- **Status:** **done** (2026-05-15) — controller projects a `secondaryChatsByInvokedTurnId: ReadonlyMap` from `specificationState.secondaryChats`; view threads it into [WorkspaceTranscriptArtifacts](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/_view/-workspace-transcript-artifacts.tsx); a new `getArtifactAnchorTurnId` helper resolves the anchor turn id for each artifact kind (`answered-turn`, `prefaced-question`, `answered-review-turn`, `answered-revision-review`, `collapsed-review-turn`, `accepted-closure`, `persisted-turn`, `active-prefaced-question`, `phase-summary`); `` instances are rendered in a `data-testid="secondary-chats-for-turn-{id}"` slot beneath each matching artifact. +- **What:** `WorkspaceTranscriptArtifacts` accepts a `secondaryChatsByInvokedTurnId` map prop and renders `` after each turn artifact whose id matches a key. `-continuous-workspace-controller.ts` projects `specificationState.secondaryChats` into the map and threads it through; `-continuous-workspace-view.tsx` passes it to the artifacts renderer. +- **Acceptance:** fixture-seeded secondary chat appears under the right turn; collapsed by default; no orphan render when the parent turn is unrendered. All three covered by tests. +- **Verification:** `npm run verify` — 102 test files / 1295 tests pass; build clean. New tests: + - [`-workspace-transcript-artifacts.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/_view/__tests__/-workspace-transcript-artifacts.test.tsx) — 4 tests covering inline rendering after the matching turn, collapsed-by-default, no-orphan when the anchor turn isn't in the stream, and multiple chats per turn. + - [`-continuous-workspace-view.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/_view/__tests__/-continuous-workspace-view.test.tsx) — added prop-threading test asserting the controller's `secondaryChatsByInvokedTurnId` reaches the artifacts renderer by reference. + +#### C3c-wire — Client: trigger that calls the C3c-route POST + invalidates bundle + +- **Status:** **done** (2026-05-15) — `useCreateSecondaryChatMutation` mutation + `SecondaryChatTriggerProvider` context landed in [src/client/components/secondary-chat-trigger.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/secondary-chat-trigger.tsx); provider is mounted in `route.tsx` alongside `SideChatHost`; `ItemActionRail` in [-structured-list-view.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/routes/specification/%24id/-structured-list-view.tsx) gains an `Open inline chat` button (`data-graph-action="open-inline-chat"`, MessagesSquare icon) alongside the existing chat-with popover trigger. `specificationSchema` now exposes `primary_chat_id` (nullable+optional for transition) so the client can resolve the parent chat without a new endpoint. +- **What:** New `useCreateSecondaryChatMutation(specificationId)` hook posts to `/api/specifications/:id/secondary-chats` with `{ parentChatId, invokedInTurnId, itemKind, itemId, spanHint? }` and invalidates the bundle on success. `SecondaryChatTriggerProvider` reads `specificationState.specification.primary_chat_id` (parent) + `active_turn_id` (anchor) and exposes a `create({ kind, id })` callback through `useSecondaryChatTrigger()`. The button is disabled when either is missing or while a create is in flight. +- **Acceptance:** clicking the new trigger creates a secondary chat and reveals an inline collapsible (via C3c-mount) without disturbing the existing popover path. Verified by mutation tests + bundle invalidation; UI button surfaces alongside (not replacing) the chat-with popover trigger. +- **Verification:** `npm run verify` — 103 test files / 1302 tests pass; build clean. New tests in [`secondary-chat-trigger.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/secondary-chat-trigger.test.tsx) cover canCreate=true happy path, canCreate=false when `primary_chat_id` or `active_turn_id` is missing, POST payload shape, bundle invalidation on success, and no-POST when canCreate is false. + +### C4 — Ask / Edit mode toggle on secondary chats + +- **Status:** **done** (2026-05-15) — `mode` column added to `chat` (nullable text enum `explore | edit`); `createSecondaryChat` defaults to `'explore'`; new `setSecondaryChatMode` helper + `PATCH /api/specifications/:id/secondary-chats/:chatId/mode` route; `secondaryChatStateSchema.chat.mode` propagates through the bundle; `SecondaryChatCollapsible` gains an Ask/Edit toggle (sibling to the trigger to avoid nested-button); a thin `SecondaryChatCollapsibleWithMode` wrapper subscribes to `useSetSecondaryChatModeMutation` and bundle invalidation. +- **What:** Mode toggle (Ask = `explore`, Edit = `edit`) with per-mode tool sets via `getSideChatTools(mode)`; persist mode on the chat (column-based, smallest viable storage). The actual streaming-with-tools wiring for secondary chats remains a follow-up — C4 lands persistence + UI selection. `getSideChatTools(mode)` is unchanged and continues to gate edit tools when called with `chat.mode`. +- **Why fifth:** Re-establishes V3.1 functional parity for side-chat editing. +- **Verification:** `npm run verify` — 103 test files / 1317 tests pass; build clean. New tests: + - [`chat-substrate.test.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/server/chat-substrate.test.ts) — default mode='explore', explicit mode='edit', `setSecondaryChatMode` updates + invariants (rejects non-secondary chats and missing chats). + - [`app.test.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/server/app.test.ts) — PATCH happy path with bundle round-trip, 400 on invalid mode, 404 on cross-spec chatId, 404 when targeting the primary interview chat. + - [`secondary-chat-collapsible.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/secondary-chat-collapsible.test.tsx) — toggle reflects persisted mode, falls back to explore when null, click invokes `onSetMode`, no-op when clicking active mode, disabled while pending or read-only. +- **Harvest:** `getSideChatTools(mode)` (unchanged), V3.1 mode plumbing pattern (Ask/Edit semantics). +- **Out of scope (deferred to C5):** wiring the persisted mode into the secondary-chat streaming pipeline + edit-tool registration; in-thread patch staging. + +### C5 — In-thread patch staging on secondary chats + +C5 has been split into three sub-cards (C5a / C5b / C5c) for verifiable thin slices. Original "What" preserved below for reference. + +- **C5 original What:** Port #138's in-thread staged-patches strip onto the chat substrate. Patches stay turn artifacts; accepted mutations still flow through Brunch-owned handlers (no new source of semantic truth). +- **Why sixth:** Closes the Edit-mode loop end-to-end. +- **Verification (umbrella):** staging/apply/cancel tests on a secondary chat; regression on the V3.1 side-chat edit flow; `npm run verify` green at C5c. +- **Harvest:** [side-chat-route.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/side-chat-route.ts), [side-chat-prompt.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/server/side-chat-prompt.ts), [side-chat-stream.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/client/lib/side-chat-stream.ts), [side-chat-host.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/side-chat-host.tsx) (staging strip render), [patch-list-host.tsx](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/patch-list-host.tsx) + [patch-list-reducer.ts](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/patch-list-reducer.ts) (`pendingPatches` plumbing). + +#### Cross-cutting design decision (Shape A — patch-list partition seam) + +C5c needs `PatchListProvider` to keep one global event log while letting each secondary chat see *only its own* staged patches. **Decision (this thread):** add `producerChatId: number | null` to `PatchBase` and expose a new `usePatchListForChat(chatId)` hook that filters the staged slice and scopes apply/discard/editSummary to that chat's patch ids. Existing `usePatchList()` keeps current behavior (popover sees all patches; safe during transition). Reducer logic is unchanged; the partition lives at the selector layer. C8 (popover retirement) deletes the legacy `producerChatId === null` branch. + +Considered alternatives and rejected: +- **Shape B (one provider per chat):** N reducers + N applier injections; popover and inline use disjoint logs. +- **Shape C (`Map` reducer):** principled but over-engineered for V1's "popover + N inline" reality; large reducer churn. +- **Shape D (no shared abstraction):** inline duplicates the popover machinery until C8. + +Shape A wins on Ousterhout's depth test (one new field + one new hook hides the partitioning concern) and is forward-compatible with A71's future server `appendPatch(spec, patch[])` signature. + +#### C5a — Server: secondary-chat streaming endpoint + edit-tool registration + +- **Status:** **next** +- **What:** New server seam `POST /api/specifications/:id/secondary-chats/:chatId/messages` (or equivalent — confirm naming during build) that resolves the chat by id, validates `chat.parent_chat_id IS NOT NULL`, calls `getSideChatTools(chat.mode)` to gate `propose_edit` / `propose_edge` / `propose_drill_down` on Edit mode, streams an assistant turn under the secondary chat using the existing SSE shape from `side-chat-route.ts`, and persists user/assistant turns under the secondary chat's `chat_id`. Reuse `side-chat-prompt.ts` for system instructions; per-mode kickoff template enrichment (deferred from C4) lands here as a side-effect of touching the prompt path. +- **Boundary crossings:** HTTP route → spec/chat lookup → `getSideChatTools(mode)` → AI SDK stream → `appendTurn(chat_id, role, parts)`. Same shape as `side-chat-route.ts`, scoped to secondary chats. +- **Risks/assumptions:** + - RISK: `side-chat-route.ts` may have popover-specific assumptions baked in (e.g. anchor item lookup from request body) → MITIGATION: read it once before mirroring; lift only the streaming/tools shell, not the request envelope. + - ASSUMPTION: secondary chats stream into `assistant_parts` of a freshly-created turn under the secondary chat (mirrors interview chat shape) → VALIDATE: round-trip oracle (POST a message, GET the bundle, see the new turn under `secondaryChats[i].turns`). May require extending the `SecondaryChatState` bundle to include turns beyond the kickoff — confirm during build. +- **Acceptance:** + - ✓ POST with mode=`explore` streams an assistant turn; bundle round-trip surfaces the new turn under the secondary chat. + - ✓ POST with mode=`edit` registers edit tools; SSE event for `propose_edit` is emitted. + - ✓ POST against a primary chat returns 404 (refuses non-secondary chats — same invariant as PATCH mode route). + - ✓ POST against a missing chat returns 404. + - ✓ Existing `POST /side-chat` (popover) regression unaffected. +- **Verification:** Inner — Vitest integration tests in `app.test.ts` covering happy paths + 404 invariants + tool gating. Middle — round-trip oracle (POST → GET bundle → assert turn presence). No outer-loop verification at this slice. +- **Out of scope:** client composer (C5b); staging strip (C5c); per-chat patch list partition (C5c). + +#### C5b — Client: composer + stream consumer for inline secondary chats + +- **Status:** **next** (after C5a) +- **What:** + 1. Promote a `` component (per the C0–C4 review finding #1) that owns *all* per-chat mutation/streaming hooks and renders `` with the wired props. Replaces the current `SecondaryChatCollapsibleWithMode` wrapper. Wires: + - `useSetSecondaryChatModeMutation(chatId)` (existing) + - `useSecondaryChatStream(chatId)` (new — wraps the C5a SSE response into staged turns + activity) + 2. Add a small composer (text input + Send) inside the collapsible body, posting to C5a and reusing `side-chat-stream.ts` parser. + 3. Render the chat's existing turns under the collapsible body (kickoff first, then user/assistant pairs). +- **Boundary crossings:** `` → `useSecondaryChatStream` → `fetch` POST → SSE parser → derived turn list → `` body. +- **Risks/assumptions:** + - RISK: `SecondaryChatState` bundle currently exposes only `chat` + `kickoffTurn`; rendering subsequent turns needs either a per-chat `turns: Turn[]` field on the bundle or a separate `useSecondaryChatTurns(chatId)` query → MITIGATION: extend the bundle if cheap (preferred), else add a per-chat turn-list query. + - ASSUMPTION: Existing `side-chat-stream.ts` parser is generic enough to consume the C5a response without forking → VALIDATE: read the parser once during build; fork only if the SSE event vocabulary diverges. +- **Acceptance:** + - ✓ Typing in the composer + Send POSTs to C5a and renders the streaming assistant turn live in the collapsible body. + - ✓ After stream completes, bundle invalidation reveals the persisted turn unchanged on next mount. + - ✓ `` replaces `SecondaryChatCollapsibleWithMode` in `-workspace-transcript-artifacts.tsx` with no regression in the C4 mode-toggle tests. + - ✓ Multiple secondary chats can be composed against in parallel without state cross-talk (no shared in-flight ref). +- **Verification:** Inner — happy-dom Vitest covering composer → POST → stream consumption → derived turn list. Middle — bundle round-trip after stream ends. Reuse `secondary-chat-collapsible.test.tsx` patterns for harness. +- **Out of scope:** patch staging strip (C5c); patch list partition (C5c); typing-while-streaming queue. + +#### C5c — Per-chat patch staging strip + partition seam + +- **Status:** **next** (after C5b) +- **What:** Land the Shape A partition seam (above) and surface the staged-patches strip *inside* ``'s collapsible body, scoped to the host's chat id. + 1. **Reducer change:** add `producerChatId: number | null` to `PatchBase` and `StagePatchInput`. Existing call sites (popover, manual tests) pass `null`. + 2. **Provider change:** new `usePatchListForChat(chatId)` hook that returns the filtered staged slice + scoped actions (apply/discard/editSummary auto-filter by chat id; apply uses `patchIds` derived from the slice). + 3. **Stream wire-up:** C5b's `useSecondaryChatStream(chatId)` translates `propose_*` SSE tool calls into `actions.stage({ ...patch, producerChatId: chatId })`. + 4. **UI:** harvest `SideChatPopover`'s staged-patches strip render shape (`stagedPatches`, `onApply`, `onUndo`, `` for `edit` patches, ``) into a `` component mounted inside ``'s collapsible body. +- **Boundary crossings:** SSE stream → `usePatchListForChat(chatId).actions.stage` → reducer event log → `usePatchListForChat(chatId).staged` → strip UI → `actions.apply()` → existing `makeEditApplier` (unchanged). +- **Risks/assumptions:** + - RISK: existing call sites (popover, side-chat-host derived state at lines 578–602) need `producerChatId: null` threaded through without semantic change → MITIGATION: type the field as required-but-nullable on `PatchBase`; let the type system surface every site. + - RISK: undo currently reverses the last apply batch globally; per-chat undo could cross chats if a popover apply followed an inline apply → MITIGATION: for V1 ship per-`apply()`-batch undo (chat scope is implicit because each chat's apply only touches its own patch ids); document the invariant in the reducer header. + - ASSUMPTION: `` and `` are reusable as-is outside the popover → VALIDATE: read both during build; lift to a shared location if needed (no new abstraction unless the second caller forces it). +- **Acceptance:** + - ✓ Staging an `edit` proposal during streaming surfaces it in the host's strip; popover does NOT see it via `usePatchList()` (filter excludes per-chat patches by default — adjust if popover-during-transition wants the full union view). + - ✓ Apply on the strip mutates the anchor item via `makeEditApplier`; undo reverses it; bundle round-trip reflects the change. + - ✓ Popover staging path (V3.1) is unaffected: existing side-chat tests pass with `producerChatId: null`. + - ✓ Two open inline secondary chats can stage edits in parallel; each strip shows only its own patches. +- **Verification:** Inner — reducer/state unit tests for `producerChatId` filtering; per-chat hook unit tests; popover regression in `side-chat-host.test.tsx`. Middle — round-trip: stage → apply → bundle reflects mutation. Outer — manual: open two inline secondary chats, stage edits in each, apply one, verify the other strip is untouched. (Capture in the C10 walkthrough.) +- **Out of scope:** rendering staged patches as turn artifacts (deferred — patches stay UI state, not turn-persisted, until a future card promotes them); cross-chat undo; deletion of `usePatchList()` (waits for C8). + +##### Order discipline + +C5a (server) → C5b (client composer + host) → C5c (partition + strip). Sequential because C5b consumes C5a's response shape; C5c's stream wire-up plugs into C5b's host. None of C5b's interface should change based on C5a build findings beyond response-shape details (those are absorbed in `useSecondaryChatStream`); C5c's interface is independent of either earlier slice. + +### C6 — `#` knowledge-item symbol injection (V1 surface only) + +- **What:** Implement `#REF-CODE` resolution in the secondary-chat composer that inserts an item context snapshot artifact into the next turn. **No** autocomplete chip; **no** `$` secondary-chat mention symbol; **no** snapshot builder lifecycle (those are Track 5 / `chat-context-provision`). Use a server-owned resolver scoped to the specification per `CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.5. +- **Why seventh:** Provides the V1 structured way to add item context, replacing the ad-hoc V3.1 anchoring path for in-flight mentions. +- **Verification:** resolver unit tests for valid/missing/ambiguous codes; turn-snapshot insertion test; manual walkthrough. + +### C7 — Agent-run inline rendering + `chat.kind` decision + +- **What:** Decide and implement: (a) keep enum at `interview` + `side_chat` and project `agent_run` flavor from `first_turn_role='system'`; (b) add a fifth `chat.kind='agent_run'` enum value. Default posture per HANDOFF: (a). Render agent-run secondary chats inline using the same component from C3. If (b) is chosen, this card carries a follow-up substrate migration. +- **Why eighth:** Agent-run inline is in V1 scope per HANDOFF; deferring to last lets the substrate decision settle after C1–C6 reveal whether projection-only is sufficient. +- **Verification:** agent-run secondary chat renders inline; system-first frontier turn invariant holds; if (b), enum migration applies cleanly. + +### C8 — `SideChatPopover` retirement + `side-chat-host` shrinkage + +- **What:** Delete `SideChatPopover`; shrink `side-chat-host` to its minimal post-popover form (target ~95 LOC per #138's harvest). Remove popover-only routes/state. +- **Why ninth:** Retire only after C3–C7 reach parity over durable secondary chats. +- **Verification:** `npm run verify`; manual regression on side-chat entry from substantive reconciliation rows; ensure no popover code paths remain reachable. + +### C9 — Lightweight reconciliation-element view + +- **Status:** **done** (2026-05-17) — `drizzle/0022_chat_pinned_reconciliation_need.sql` adds the nullable FK column on `chat`; `createSecondaryChat` + the `POST /api/specifications/:id/secondary-chats` payload accept an optional `reconciliationNeedId` (server rejects cross-spec needs with 404); `listSecondaryChatsForSpecification` joins the need + both knowledge items at read time and surfaces a `pinnedReconciliationNeed: { needId, kind, sourceItemId/RefCode/Excerpt, targetItemId/RefCode/Excerpt }` projection on each `SecondaryChat`; `SecondaryChatTriggerItem.reconciliationNeedId` is threaded through `useCreateSecondaryChatMutation`; `PendingReviewSection.handleOpenSideChat` passes `need.id` alongside `target_item_kind`/`target_item_id`; `SecondaryChatCollapsible` renders a small `data-testid="secondary-chat-reconciliation-panel"` band (kind label + per-endpoint ref code + truncated excerpt) when the field is populated. Other trigger paths (StructuredListView, etc.) are unchanged and continue to omit `reconciliationNeedId`. +- **What:** When a secondary chat is opened with a reconciliation context (entry bridge from a substantive reconciliation row), render a minimal "elements being reconciled" panel inside the secondary chat surface. **Not** the full target-grouped / classifier-state UX from the brief — that's Track 3 (`reconciliation-runtime`). `PendingReviewSection` retirement stays Track 3's job. +- **Verification:** `npm run verify` — 104 test files / 1252 tests pass; build clean. New tests: + - [`app.test.ts`](file:///Users/kostandin/Projects/hashdev/brunch/src/server/app.test.ts) — POST persists `pinned_reconciliation_need_id`, bundle round-trip surfaces `pinnedReconciliationNeed` with `kind` + source/target ref-code & excerpt joins; cross-spec need id returns 404. + - [`secondary-chat-collapsible.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/secondary-chat-collapsible.test.tsx) — panel renders kind label + source/target ref codes & excerpts when populated; no panel when `pinnedReconciliationNeed` is null. + - [`pending-review-section.test.tsx`](file:///Users/kostandin/Projects/hashdev/brunch/src/client/components/__tests__/pending-review-section.test.tsx) — assertion updated to include `reconciliationNeedId: need.id` in the substantive `Open side-chat` trigger payload. + +### C10 — V1 closure: verification + manual walkthrough + frontier closeout + +- **Status:** **done** (2026-05-17) — `npm run verify` green (104 test files / 1252 tests pass; build clean). PLAN.md `chat-runtime-secondary-chats` frontier marked V1 done; C7 (agent-run inline) deferred to a follow-up frontier when an agent-run producer ships. SPEC.md A94 substrate hypothesis is shipped (durable secondary chats over chat/turn with no `thread` table); A94 remains `open` until the qa/strategy surfaces it enumerates are reachable, but no substrate change is required from FE-716. PR description drafted (see below; the C0–C9 commit thread is the canonical reading order). Manual walkthrough deferred until the PR moves to review; the test suite (incl. C3c-route round-trip, C5c partition, C8a/b trigger flows, C9 panel render + bundle round-trip) covers the substantive surfaces. +- **What:** Full `npm run verify`; outer-loop walkthrough of the side-chat V3.1 capability matrix on the new substrate; confirm SPEC.md A94 is satisfied (durable secondary chats over chat/turn without a `thread` table); update PLAN.md frontier status; draft PR description. +- **Verification:** `npm run verify` — 104 test files / 1252 tests pass; build clean. PLAN.md status updated. PR description below. + +#### PR description (draft) + +**Title:** `FE-716: Walking skeleton chat runtime — inline secondary chats over chat/turn` + +**Body:** + +> **What** +> +> Lands V1 of the Conversational Workspace Runtime Track 2 (`chat-runtime-secondary-chats`): every behavior the V3.1 side-chat ships today, surfaced through the elevated unified-workspace shape from `docs/design/UNIFIED_CHAT_UX.md`. Durable side-chats are now durable secondary chats over the existing `chat`/`turn` substrate; the legacy `SideChatPopover` is retired; lightweight reconciliation entry now renders inline; the `thread` table remains deferred per A94. +> +> **Substrate (no new tables)** +> +> - `chat.parent_chat_id`, `chat.invoked_in_turn_id`, `chat.pinned_item_id`, `chat.pinned_span_hint`, `chat.mode`, `chat.pinned_reconciliation_need_id` (drizzle/0020, 0021, 0022). No enum changes; secondary chats are projected from `parent_chat_id IS NOT NULL`. +> +> **Server** +> +> - `createSecondaryChat`, `createKickoffTurn`, `appendSecondaryChatTurn`, `setSecondaryChatMode`, `listSecondaryChatsForSpecification` in `specification-store.ts`. +> - `POST /api/specifications/:id/secondary-chats` (create), `PATCH …/mode` (mode toggle), `POST …/messages` (streaming SSE with `getSideChatTools(mode)` edit-tool gating + `#REF-CODE` mention resolution). +> - Bundle hydrates `secondaryChats[*]` with kickoff turn, post-kickoff turns, pinned-item kind, and joined reconciliation-need projection. +> +> **Client** +> +> - `SecondaryChatTriggerProvider` + `useSecondaryChatTrigger()` exposes one `create({ kind, id, spanHint?, reconciliationNeedId? })` callback + an `inlineChatRoute` descriptor so non-transcript callers can navigate to the transcript view. +> - `` wires per-chat mutation/streaming hooks; `` renders the kickoff card, mode toggle, composer, streaming assistant, staged-patches strip slot, and the C9 "Elements being reconciled" panel. +> - Patch-list partitioning by `producerChatId` (Shape A) — `usePatchListForChat(chatId)` returns a per-chat staged slice while the legacy popover hook keeps the global view; `` mounts inside the collapsible body. +> - Triggers: `PendingReviewSection` substantive row + `StructuredListView` item-action rail both call into `useSecondaryChatTrigger()`; `SideChatPopover` and `SideChatHost` are deleted. +> +> **Verification** +> +> - `npm run verify` — 104 test files / 1252 tests pass; build clean. +> - Coverage spans schema invariants, route happy-paths + 404 invariants, SSE chunk round-trip + bundle round-trip, partition-seam reducer + per-chat hook tests, popover-regression sweeps, and the C9 reconciliation-panel render. +> +> **Deferred (parking lot — follow-up frontiers)** +> +> `$` mention symbol, mention autocomplete, snapshot builder family, item-version-gated handle refresh, full target-grouped reconciliation UX, `PendingReviewSection` retirement, QA composer refinements, strategy sub-chat UI, layout-state header control, and C7 agent-run inline rendering (the substrate is ready; no producer exists yet). +> +> **Stacking** +> +> Stacked on `ln/fe-709-reconciliations` (PR #139). Restack on `main` once #139 lands. + +## Deferred — explicitly NOT in V1 (parking lot) + +These belong to follow-up frontiers and should not be slipped into FE-716: + +- `$` secondary-chat mention symbol → future `chat-context-provision` slice +- Mention autocomplete chip + per-kind prompt context builders → `chat-context-provision` (Track 5) +- Snapshot builder family (`buildIntentItemContextSnapshot`, neighborhood, economic-graph, historical) → `chat-context-provision` (Track 5) +- Item-version-gated handle refresh → `chat-context-provision` (Track 5; needs `changeset-ledger`) +- Full reconciliation runtime UX (target-grouped, async classifier states, "Reconcile Now") → `reconciliation-runtime` (Track 3) +- `PendingReviewSection` retirement → `reconciliation-runtime` (Track 3) +- QA composer refinements → follow-up frontier +- Strategy secondary-chat UI → follow-up frontier (substrate may already represent it) +- Layout-state header control (Compact / Side-docked / Maximize / Full) → follow-up frontier + +## Open coordination items + +- **Lexicon reconciliation:** none — branch adopts PR #139's "secondary chat" vocabulary throughout. +- **PR #139 dependency:** stack submits only after #139 merges (or per Lu's signal). Restack on `main` once #139 lands. diff --git a/memory/PLAN.md b/memory/PLAN.md index ea0e48b8..45361665 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -24,16 +24,16 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### Active 1. `agent-fixture-substrate` — branch-complete off main, reconciling — FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes. +2. `chat-runtime-secondary-chats` — FE-716; branch `ka/fe-716-chat-runtime-unified-secondary-chats` stacked on `ln/fe-709-reconciliations` (PR #139). **V1 implementation complete (C0–C9 landed; C7 agent-run inline deferred); awaiting PR review + #139 merge.** Card queue in `memory/CARDS.md` retained as a closeout reference until the PR merges; delete on `/ln-sync` thereafter. ### Next -1. `chat-runtime-secondary-chats` — Track 2 of the runtime umbrella; immediate successor to continuous-workspace. Implement inline/collapsible secondary chats over existing chat/turn; explicitly defer a `thread` table. -2. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation. -3. `changeset-ledger` — Track 4 of the runtime umbrella; parallel with Track 2; semantic history spine needed before canonical proposal acceptance, direct-edit atomicity, and productized scenario options. -4. `chat-context-provision` — Track 5 of the runtime umbrella recast as transcript-first context; can proceed against chat/turn once secondary-chat entry/anchor shape is settled. -5. `reconciliation-runtime` — Track 3 of the runtime umbrella; after Track 2 + Track 4 provide the secondary-chat surface and durable attribution. -6. `graph-review-scenario-options` — artifact-only critique/probe lane; can advance in parallel with FE-700 if it does not commit canonical graph truth. -7. `productized-scenario-options` — user-facing acceleration surface after FE-700 semantics, FE-701 changesets, and graph-review probes. +1. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation. +2. `changeset-ledger` — Track 4 of the runtime umbrella; parallel with Track 2; semantic history spine needed before canonical proposal acceptance, direct-edit atomicity, and productized scenario options. +3. `chat-context-provision` — Track 5 of the runtime umbrella recast as transcript-first context; can proceed against chat/turn once secondary-chat entry/anchor shape is settled. +4. `reconciliation-runtime` — Track 3 of the runtime umbrella; after Track 2 + Track 4 provide the secondary-chat surface and durable attribution. +5. `graph-review-scenario-options` — artifact-only critique/probe lane; can advance in parallel with FE-700 if it does not commit canonical graph truth. +6. `productized-scenario-options` — user-facing acceleration surface after FE-700 semantics, FE-701 changesets, and graph-review probes. ### Parallel / Low-conflict @@ -74,15 +74,17 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### chat-runtime-secondary-chats - **Name:** Chat runtime — inline secondary chats (Conversational Workspace Runtime — Track 2) -- **Linear:** FE-710 if retitled; otherwise unassigned in this plan snapshot +- **Linear:** FE-716 - **Kind:** structural -- **Status:** not-started / replanned +- **Status:** V1 done (awaiting PR review + #139 merge) — substrate ships durable inline secondary chats over chat/turn, SideChatPopover is retired, lightweight reconciliation panel renders inline. Agent-run inline rendering (C7) deferred until an agent-run producer exists; full reconciliation runtime UX, `$` mention symbol, snapshot builder family, item-version-gated refresh, qa composer refinements, strategy sub-chat UI, and layout-state header control all moved to follow-up frontiers per the V1 parking lot in `memory/CARDS.md`. - **Objective:** Render side, reconciliation, qa, and strategy chats inline as collapsible secondary chats in the workspace using the existing chat/turn substrate. Defer schema-level `thread`; do not add `thread` / `turn.thread_id` unless a later RFC proves chat/turn insufficient. Retire the SideChatPopover as a UI surface only after parity exists over durable secondary chats. +- **V1 narrowing (FE-716 scope):** Frame V1 as "every behavior the current side-chat (V3.1) supports today, surfaced through the elevated unified-workspace shape." Build only what that framing requires: substrate columns on `chat` (`parent_chat_id`, `invoked_in_turn_id`, `pinned_item_id`, `pinned_span_hint`) without enum changes; durable secondary-chat persistence; inline collapsible rendering; turn-zero kickoff with server-supplied snapshots; Ask/Edit modes; `#` knowledge-item symbol injection only; lightweight reconciliation-element view (full reconciliation runtime stays Track 3); agent-run inline rendering; SideChatPopover deletion. Explicitly defer to follow-up frontiers: `$` secondary-chat mention symbol, full reconciliation target-grouped UX, QA composer refinements, strategy sub-chat UI, layout-state header control, mention autocomplete, snapshot builders, item-version-gated refresh. Design brief `docs/design/UNIFIED_CHAT_UX.md` is the canonical reference for the broader ceiling and stays unedited. - **Why now / unlocks:** Track 1 (workspace shell) ships, providing the stable host. Inline secondary chats are the critical unblocker for reconciliation absorption (Track 3) and give chat-context provision (Track 5) stable initiating anchors without creating a competing strategy/context substrate. Supersedes the prior side-chat V4a persistence horizon — persistent side-chat history becomes durable secondary chats rendered inline. -- **Acceptance:** Secondary chat kinds (`side`, `reconciliation`, `qa`, `strategy`) are representable with chat/turn; each active/resumable chat preserves one open assistant/system-first frontier turn; secondary chats render inline/collapsible in the unified workspace; SideChatPopover retires as cutover; transient staged-patches strip does not become a new source of semantic truth; turn-zero (`turn_kind='kickoff'`) seeds secondary chats with explicit context snapshots. +- **Acceptance:** Secondary chat kinds (`side`, `reconciliation`, `qa`, `strategy`) are representable with chat/turn; each active/resumable chat preserves one open assistant/system-first frontier turn; secondary chats render inline/collapsible in the unified workspace; SideChatPopover retires as cutover; transient staged-patches strip does not become a new source of semantic truth; turn-zero (`turn_kind='kickoff'`) seeds secondary chats with explicit context snapshots (full snapshot lifecycle deferred to Track 5). - **Verification:** Chat/turn persistence and reload tests, inline secondary-chat rendering tests, one-open-frontier-per-chat tests, manual walkthroughs for side/qa/strategy chat creation/display/collapse, and regression on existing interview flow. +- **Open question (resolve in Card 1 / Card 6):** Agent-run inline rendering — fifth `chat.kind` enum value, system-authored sub-chat reusing an existing kind, or a derived projection over `first_turn_role`. HANDOFF flagged for explicit decision; default posture is to keep the enum at `interview` + `side_chat` and project agent-run from `first_turn_role='system'` unless substrate behavior justifies promotion. - **Traceability:** Requirement 45; A49, A94; D86, D87, D110, D114, D138, D153; I111, I116, I120. -- **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.2 + §5 Track 2; `docs/design/MULTI_CHAT.md`; `docs/design/SIDE_CHAT.md`; `docs/design/SPEC_EVOLUTION_STRATEGIES.md`. +- **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.2 + §5 Track 2; `docs/design/MULTI_CHAT.md`; `docs/design/SIDE_CHAT.md`; `docs/design/SPEC_EVOLUTION_STRATEGIES.md`; design brief `docs/design/UNIFIED_CHAT_UX.md` (to be brought forward verbatim from PR #138 in Card 0; do not edit). ### reconciliation-runtime diff --git a/src/client/components/__tests__/patch-list-overlay.test.tsx b/src/client/components/__tests__/patch-list-overlay.test.tsx index fef5b1a0..97aa8c9e 100644 --- a/src/client/components/__tests__/patch-list-overlay.test.tsx +++ b/src/client/components/__tests__/patch-list-overlay.test.tsx @@ -62,6 +62,7 @@ function StageEditPatchButton() { onClick={() => patchList?.stage({ kind: 'edit', + producerChatId: null, anchor: { kind: 'goal', itemId: 1 }, summary: 'Edit: rephrase', newContent: 'rephrased content', @@ -81,6 +82,7 @@ function StageEditPatchWithDiffButton() { onClick={() => patchList?.stage({ kind: 'edit', + producerChatId: null, anchor: { kind: 'goal', itemId: 1 }, anchorReferenceCode: 'G1', summary: 'Edit: swap database', @@ -103,6 +105,7 @@ function StageAnnotatePatchButton() { onClick={() => patchList?.stage({ kind: 'annotate', + producerChatId: null, anchor: { kind: 'goal', itemId: 2 }, summary: 'Note: clarify exclusion', body: 'The exclusion clause should be moved up.', @@ -122,6 +125,7 @@ function StageHardEditButton() { onClick={() => patchList?.stage({ kind: 'edit', + producerChatId: null, anchor: { kind: 'goal', itemId: 3 }, summary: 'Hard: restructure', currentContent: 'before', diff --git a/src/client/components/__tests__/pending-review-section.test.tsx b/src/client/components/__tests__/pending-review-section.test.tsx index ceb95493..431b5499 100644 --- a/src/client/components/__tests__/pending-review-section.test.tsx +++ b/src/client/components/__tests__/pending-review-section.test.tsx @@ -67,15 +67,22 @@ vi.mock('@/client/lib/edit-api.js', () => ({ resetReconciliationNeedAgentRequest: mockResetReconciliationNeedAgentRequest, })); -const mockSideChatOpenFor = vi.hoisted(() => vi.fn()); -let sideChatContextValue: { openFor: typeof mockSideChatOpenFor } | null = { - openFor: mockSideChatOpenFor, +const mockSecondaryChatCreate = vi.hoisted(() => vi.fn()); +interface SecondaryChatTriggerStub { + canCreate: boolean; + isPending: boolean; + create: typeof mockSecondaryChatCreate; +} +let secondaryChatTriggerValue: SecondaryChatTriggerStub | null = { + canCreate: true, + isPending: false, + create: mockSecondaryChatCreate, }; -function setSideChatContext(value: { openFor: typeof mockSideChatOpenFor } | null): void { - sideChatContextValue = value; +function setSecondaryChatTrigger(value: SecondaryChatTriggerStub | null): void { + secondaryChatTriggerValue = value; } -vi.mock('../side-chat-host.js', () => ({ - useSideChat: () => sideChatContextValue, +vi.mock('../secondary-chat-trigger.js', () => ({ + useSecondaryChatTrigger: () => secondaryChatTriggerValue, })); beforeEach(() => { @@ -118,8 +125,9 @@ afterEach(() => { agentProposal: null, }), ); - mockSideChatOpenFor.mockClear(); - setSideChatContext({ openFor: mockSideChatOpenFor }); + mockSecondaryChatCreate.mockClear(); + mockSecondaryChatCreate.mockImplementation(() => Promise.resolve(null)); + setSecondaryChatTrigger({ canCreate: true, isPending: false, create: mockSecondaryChatCreate }); vi.useRealTimers(); }); @@ -719,7 +727,7 @@ describe('PendingReviewSection', () => { expect(container.querySelector('[data-skip-button="1"]')).not.toBeNull(); }); - it('substantive row renders Open side-chat button that invokes useSideChat().openFor', async () => { + it('substantive row renders Open side-chat button that invokes the secondary-chat trigger', async () => { setMockOpenNeeds([ makeNeed({ id: 1, @@ -736,17 +744,32 @@ describe('PendingReviewSection', () => { await act(async () => { fireEvent.click(screen.getByRole('button', { name: /open side-chat for need 1/i })); }); - expect(mockSideChatOpenFor).toHaveBeenCalledTimes(1); - expect(mockSideChatOpenFor).toHaveBeenCalledWith({ + expect(mockSecondaryChatCreate).toHaveBeenCalledTimes(1); + expect(mockSecondaryChatCreate).toHaveBeenCalledWith({ kind: 'requirement', id: 99, - referenceCode: 'R3', - content: 'substantive content', + reconciliationNeedId: 1, }); }); - it('substantive row hides Open side-chat when useSideChat returns null', () => { - setSideChatContext(null); + it('substantive row hides Open side-chat when the secondary-chat trigger is unavailable', () => { + setSecondaryChatTrigger(null); + setMockOpenNeeds([ + makeNeed({ + id: 1, + agent_status: 'classified', + agent_classification: 'substantive', + target_item_kind: 'requirement', + target_reference_code: 'R3', + target_current_content: 'substantive', + }), + ]); + const { container } = render(); + expect(container.querySelector('[data-open-side-chat-button]')).toBeNull(); + }); + + it('substantive row hides Open side-chat when the trigger reports it cannot create yet', () => { + setSecondaryChatTrigger({ canCreate: false, isPending: false, create: mockSecondaryChatCreate }); setMockOpenNeeds([ makeNeed({ id: 1, diff --git a/src/client/components/__tests__/secondary-chat-collapsible.test.tsx b/src/client/components/__tests__/secondary-chat-collapsible.test.tsx new file mode 100644 index 00000000..597ac2e8 --- /dev/null +++ b/src/client/components/__tests__/secondary-chat-collapsible.test.tsx @@ -0,0 +1,361 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { secondaryChatStateSchema } from '@/shared/api-types.js'; + +import { SecondaryChatCollapsible } from '../secondary-chat-collapsible.js'; + +type SecondaryChat = z.infer; + +const baseChat: SecondaryChat['chat'] = { + id: 7, + specification_id: 1, + kind: 'side_chat', + parent_chat_id: 1, + invoked_in_turn_id: 3, + pinned_item_id: null, + pinned_span_hint: null, + pinned_reconciliation_need_id: null, + mode: 'explore', +}; + +afterEach(() => cleanup()); + +describe('SecondaryChatCollapsible', () => { + it('renders the header for a secondary chat with a kickoff turn', () => { + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: { + id: 99, + specification_id: 1, + parent_turn_id: null, + phase: 'grounding', + turn_kind: 'kickoff', + question: '', + why: null, + impact: null, + answer: null, + is_resolution: false, + user_parts: null, + assistant_parts: 'Editing this item.', + created_at: '', + }, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + + render(); + + expect(screen.getByTestId('secondary-chat-collapsible')).toBeTruthy(); + expect(screen.getByTestId('secondary-chat-collapsible-trigger')).toBeTruthy(); + }); + + it('starts collapsed — body content is not visible', () => { + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: { + id: 99, + specification_id: 1, + parent_turn_id: null, + phase: 'grounding', + turn_kind: 'kickoff', + question: '', + why: null, + impact: null, + answer: null, + is_resolution: false, + user_parts: null, + assistant_parts: 'Editing this item.', + created_at: '', + }, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + + render(); + + expect(screen.queryByText('Editing this item.')).toBeNull(); + }); + + it('expands on trigger click and reveals kickoff turn assistant_parts', () => { + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: { + id: 99, + specification_id: 1, + parent_turn_id: null, + phase: 'grounding', + turn_kind: 'kickoff', + question: '', + why: null, + impact: null, + answer: null, + is_resolution: false, + user_parts: null, + assistant_parts: 'Editing this item.', + created_at: '', + }, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + + render(); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + + expect(screen.getByText('Editing this item.')).toBeTruthy(); + }); + + it('renders an empty body when no kickoff turn exists', () => { + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + + render(); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + + const body = screen.getByTestId('secondary-chat-collapsible-body'); + expect(body.textContent?.trim()).toBe(''); + }); + + it('renders the mode toggle reflecting the persisted mode', () => { + const chat: SecondaryChat = { + chat: { ...baseChat, mode: 'edit' }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + const toggle = screen.getByTestId('secondary-chat-mode-toggle'); + expect(toggle.dataset.mode).toBe('edit'); + expect(screen.getByTestId('secondary-chat-mode-edit').getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByTestId('secondary-chat-mode-ask').getAttribute('aria-pressed')).toBe('false'); + }); + + it('falls back to explore mode when chat.mode is null', () => { + const chat: SecondaryChat = { + chat: { ...baseChat, mode: null }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + const toggle = screen.getByTestId('secondary-chat-mode-toggle'); + expect(toggle.dataset.mode).toBe('explore'); + }); + + it('invokes onSetMode when the user clicks a different mode', () => { + const onSetMode = vi.fn(); + const chat: SecondaryChat = { + chat: { ...baseChat, mode: 'explore' }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + fireEvent.click(screen.getByTestId('secondary-chat-mode-edit')); + expect(onSetMode).toHaveBeenCalledWith('edit'); + }); + + it('does not invoke onSetMode when clicking the already-active mode', () => { + const onSetMode = vi.fn(); + const chat: SecondaryChat = { + chat: { ...baseChat, mode: 'explore' }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + fireEvent.click(screen.getByTestId('secondary-chat-mode-ask')); + expect(onSetMode).not.toHaveBeenCalled(); + }); + + it('disables the toggle while a mode update is in flight', () => { + const onSetMode = vi.fn(); + const chat: SecondaryChat = { + chat: { ...baseChat, mode: 'explore' }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + expect(screen.getByTestId('secondary-chat-mode-edit').hasAttribute('disabled')).toBe(true); + fireEvent.click(screen.getByTestId('secondary-chat-mode-edit')); + expect(onSetMode).not.toHaveBeenCalled(); + }); + + it('disables the toggle when no onSetMode handler is provided (read-only display)', () => { + const chat: SecondaryChat = { + chat: { ...baseChat, mode: 'explore' }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + expect(screen.getByTestId('secondary-chat-mode-edit').hasAttribute('disabled')).toBe(true); + }); +}); + +describe('SecondaryChatCollapsible — turns + composer (C5b)', () => { + function makeUserTurn(id: number, text: string): SecondaryChat['turns'][number] { + return { + id, + specification_id: 1, + parent_turn_id: null, + phase: 'grounding', + turn_kind: 'question', + question: '', + why: null, + impact: null, + answer: null, + is_resolution: false, + user_parts: text, + assistant_parts: null, + created_at: '', + }; + } + + function makeAssistantTurn(id: number, text: string): SecondaryChat['turns'][number] { + return { + id, + specification_id: 1, + parent_turn_id: null, + phase: 'grounding', + turn_kind: 'question', + question: '', + why: null, + impact: null, + answer: null, + is_resolution: false, + user_parts: null, + assistant_parts: text, + created_at: '', + }; + } + + it('renders persisted user/assistant turns under the kickoff body when expanded', () => { + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [makeUserTurn(10, 'why?'), makeAssistantTurn(11, 'because.')], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + expect(screen.getByText('why?')).toBeTruthy(); + expect(screen.getByText('because.')).toBeTruthy(); + }); + + it('renders the composer when onSubmitMessage is provided and submits trimmed text', () => { + const onSubmitMessage = vi.fn(); + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + const input = screen.getByTestId('secondary-chat-composer-input') as HTMLInputElement; + fireEvent.change(input, { target: { value: ' hello ' } }); + fireEvent.click(screen.getByTestId('secondary-chat-composer-send')); + expect(onSubmitMessage).toHaveBeenCalledWith('hello'); + expect(input.value).toBe(''); + }); + + it('does not render the composer when onSubmitMessage is omitted', () => { + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + expect(screen.queryByTestId('secondary-chat-composer')).toBeNull(); + }); + + it('renders streaming assistant text and disables the composer while isStreaming is true', () => { + const onSubmitMessage = vi.fn(); + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render( + , + ); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + expect(screen.getByTestId('secondary-chat-streaming-assistant').textContent).toBe('streaming reply...'); + expect((screen.getByTestId('secondary-chat-composer-input') as HTMLInputElement).disabled).toBe(true); + }); + + it('renders the reconciliation panel when pinnedReconciliationNeed is set', () => { + const chat: SecondaryChat = { + chat: { ...baseChat, pinned_reconciliation_need_id: 42 }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: { + needId: 42, + kind: 'supersedes', + sourceItemId: 10, + sourceRefCode: 'G2', + sourceExcerpt: 'updated goal text', + targetItemId: 20, + targetRefCode: 'R5', + targetExcerpt: 'existing requirement text', + }, + }; + render(); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + const panel = screen.getByTestId('secondary-chat-reconciliation-panel'); + expect(panel.getAttribute('data-reconciliation-need-id')).toBe('42'); + expect(panel.getAttribute('data-reconciliation-kind')).toBe('supersedes'); + expect(panel.textContent).toContain('Supersedes'); + const source = screen.getByTestId('secondary-chat-reconciliation-source'); + expect(source.textContent).toContain('G2'); + expect(source.textContent).toContain('updated goal text'); + const target = screen.getByTestId('secondary-chat-reconciliation-target'); + expect(target.textContent).toContain('R5'); + expect(target.textContent).toContain('existing requirement text'); + }); + + it('does not render the reconciliation panel when pinnedReconciliationNeed is null', () => { + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + render(); + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + expect(screen.queryByTestId('secondary-chat-reconciliation-panel')).toBeNull(); + }); +}); diff --git a/src/client/components/__tests__/secondary-chat-host.test.tsx b/src/client/components/__tests__/secondary-chat-host.test.tsx new file mode 100644 index 00000000..9f5d5b56 --- /dev/null +++ b/src/client/components/__tests__/secondary-chat-host.test.tsx @@ -0,0 +1,242 @@ +// @vitest-environment happy-dom + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Suspense, type ReactElement, type ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { z } from 'zod/v4'; + +import { specificationQueryKeys } from '@/client/routes/specification/$id/-specification-data.js'; +import { secondaryChatStateSchema } from '@/shared/api-types.js'; +import type { SpecificationState } from '@/shared/specification.js'; + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ id: '1' }), +})); + +const { mockStream } = vi.hoisted(() => ({ + mockStream: vi.fn(), +})); + +vi.mock('@/client/lib/secondary-chat-stream.js', () => ({ + streamSecondaryChatMessage: mockStream, +})); + +const { SecondaryChatHost } = await import('../secondary-chat-host.js'); + +type SecondaryChat = z.infer; + +const baseChat: SecondaryChat['chat'] = { + id: 7, + specification_id: 1, + kind: 'side_chat', + parent_chat_id: 1, + invoked_in_turn_id: 3, + pinned_item_id: 5, + pinned_span_hint: null, + pinned_reconciliation_need_id: null, + mode: 'explore', +}; + +function buildSpec(): SpecificationState { + return { + specification: { + id: 1, + name: 'Test', + mode: 'greenfield', + active_turn_id: 42, + primary_chat_id: 7, + created_at: '2026-04-12 10:00:00', + updated_at: '2026-04-12 10:00:00', + }, + workflow: { + phases: { + grounding: { + status: 'in_progress', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + design: { + status: 'unstarted', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + requirements: { + status: 'unstarted', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + criteria: { + status: 'unstarted', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + }, + }, + turns: [], + }; +} + +function createHarness(): { + queryClient: QueryClient; + Wrapper: ({ children }: { children: ReactNode }) => ReactElement; +} { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(specificationQueryKeys.bundle('1'), buildSpec()); + const Wrapper = ({ children }: { children: ReactNode }) => ( + + }>{children} + + ); + return { queryClient, Wrapper }; +} + +beforeEach(() => { + mockStream.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('SecondaryChatHost — composer wiring (C5b)', () => { + it('submits the composer message to streamSecondaryChatMessage with the right ids', async () => { + mockStream.mockResolvedValue(undefined); + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + const { Wrapper } = createHarness(); + + render( + + + , + ); + + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + fireEvent.change(screen.getByTestId('secondary-chat-composer-input'), { + target: { value: 'why?' }, + }); + fireEvent.click(screen.getByTestId('secondary-chat-composer-send')); + + await waitFor(() => { + expect(mockStream).toHaveBeenCalled(); + }); + const [request] = mockStream.mock.calls[0]!; + expect(request).toMatchObject({ specificationId: 1, chatId: 7, message: 'why?' }); + }); + + it('renders streaming assistant text deltas live and invalidates the bundle when the stream completes', async () => { + mockStream.mockImplementation( + async ( + _request: { specificationId: number; chatId: number; message: string }, + onChunk: (event: { type: string; delta?: string }) => void, + ) => { + onChunk({ type: 'text-delta', delta: 'Hello ' }); + onChunk({ type: 'text-delta', delta: 'world.' }); + }, + ); + const chat: SecondaryChat = { + chat: baseChat, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + const { queryClient, Wrapper } = createHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + render( + + + , + ); + + fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger')); + fireEvent.change(screen.getByTestId('secondary-chat-composer-input'), { + target: { value: 'hi' }, + }); + fireEvent.click(screen.getByTestId('secondary-chat-composer-send')); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalled(); + }); + expect( + invalidateSpy.mock.calls.some( + ([args]) => Array.isArray(args?.queryKey) && args.queryKey[0] === 'specification', + ), + ).toBe(true); + }); + + it('isolates in-flight state across two host instances (no cross-talk)', async () => { + let resolveOne = (): void => {}; + mockStream.mockImplementationOnce(async () => { + await new Promise((resolve) => { + resolveOne = resolve; + }); + }); + mockStream.mockResolvedValueOnce(undefined); + const chatA: SecondaryChat = { + chat: { ...baseChat, id: 7 }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + const chatB: SecondaryChat = { + chat: { ...baseChat, id: 8 }, + kickoffTurn: null, + turns: [], + pinnedItemKind: null, + pinnedReconciliationNeed: null, + }; + const { Wrapper } = createHarness(); + + render( + + + + , + ); + + const collapsibles = screen.getAllByTestId('secondary-chat-collapsible-trigger'); + fireEvent.click(collapsibles[0]); + fireEvent.click(collapsibles[1]); + + const inputs = screen.getAllByTestId('secondary-chat-composer-input') as HTMLInputElement[]; + const sends = screen.getAllByTestId('secondary-chat-composer-send'); + fireEvent.change(inputs[0], { target: { value: 'a?' } }); + fireEvent.change(inputs[1], { target: { value: 'b?' } }); + fireEvent.click(sends[0]); // chat A streams, hangs + fireEvent.click(sends[1]); // chat B streams, resolves immediately + + await waitFor(() => { + expect(mockStream).toHaveBeenCalledTimes(2); + }); + expect(mockStream.mock.calls[0]?.[0]).toMatchObject({ chatId: 7, message: 'a?' }); + expect(mockStream.mock.calls[1]?.[0]).toMatchObject({ chatId: 8, message: 'b?' }); + + // Resolve A so Vitest doesn't leak the pending stream. + resolveOne(); + }); +}); diff --git a/src/client/components/__tests__/secondary-chat-trigger.test.tsx b/src/client/components/__tests__/secondary-chat-trigger.test.tsx new file mode 100644 index 00000000..cb41a1cc --- /dev/null +++ b/src/client/components/__tests__/secondary-chat-trigger.test.tsx @@ -0,0 +1,207 @@ +// @vitest-environment happy-dom + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Suspense, type ReactElement, type ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + SecondaryChatTriggerProvider, + useSecondaryChatTrigger, +} from '@/client/components/secondary-chat-trigger.js'; +import { specificationQueryKeys } from '@/client/routes/specification/$id/-specification-data.js'; +import type { SpecificationState } from '@/shared/specification.js'; + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ id: '1' }), +})); + +const mockFetch = vi.fn(); + +function buildSpecificationState( + overrides: Partial = {}, +): SpecificationState { + return { + specification: { + id: 1, + name: 'Test', + mode: 'greenfield', + active_turn_id: 42, + primary_chat_id: 7, + created_at: '2026-04-12 10:00:00', + updated_at: '2026-04-12 10:00:00', + ...overrides, + }, + workflow: { + phases: { + grounding: { + status: 'in_progress', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + design: { + status: 'unstarted', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + requirements: { + status: 'unstarted', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + criteria: { + status: 'unstarted', + closeability: false, + readiness: 'low', + closureBasis: null, + proposalPending: false, + turnId: null, + summary: null, + }, + }, + }, + turns: [], + }; +} + +function createHarness(state: SpecificationState): { + queryClient: QueryClient; + Wrapper: ({ children }: { children: ReactNode }) => ReactElement; +} { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(specificationQueryKeys.bundle('1'), state); + const Wrapper = ({ children }: { children: ReactNode }) => ( + + }> + {children} + + + ); + return { queryClient, Wrapper }; +} + +function TriggerProbe() { + const trigger = useSecondaryChatTrigger(); + return ( +
+
{trigger?.canCreate ? 'yes' : 'no'}
+ +
+ ); +} + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('SecondaryChatTriggerProvider', () => { + it('reports canCreate=true when primary_chat_id and active_turn_id are present', () => { + const { Wrapper } = createHarness(buildSpecificationState()); + render( + + + , + ); + expect(screen.getByTestId('can-create').textContent).toBe('yes'); + }); + + it('reports canCreate=false when primary_chat_id is missing', () => { + const { Wrapper } = createHarness(buildSpecificationState({ primary_chat_id: null })); + render( + + + , + ); + expect(screen.getByTestId('can-create').textContent).toBe('no'); + }); + + it('reports canCreate=false when active_turn_id is null', () => { + const { Wrapper } = createHarness(buildSpecificationState({ active_turn_id: null })); + render( + + + , + ); + expect(screen.getByTestId('can-create').textContent).toBe('no'); + }); + + it('POSTs to /api/specifications/:id/secondary-chats with the resolved parent chat id, anchor turn id, and item ref, then invalidates the bundle', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ chatId: 100, kickoffTurnId: 200 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const { queryClient, Wrapper } = createHarness(buildSpecificationState()); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + render( + + + , + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const [url, init] = mockFetch.mock.calls[0]!; + expect(url).toBe('/api/specifications/1/secondary-chats'); + expect(init?.method).toBe('POST'); + const rawBody = init?.body; + expect(typeof rawBody).toBe('string'); + const body = JSON.parse(rawBody as string); + expect(body).toEqual({ + parentChatId: 7, + invokedInTurnId: 42, + itemKind: 'goal', + itemId: 99, + }); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalled(); + }); + expect( + invalidateSpy.mock.calls.some( + ([args]) => Array.isArray(args?.queryKey) && args.queryKey[0] === 'specification', + ), + ).toBe(true); + }); + + it('does not POST when canCreate is false', () => { + const { Wrapper } = createHarness(buildSpecificationState({ active_turn_id: null })); + render( + + + , + ); + fireEvent.click(screen.getByText('Create')); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client/components/__tests__/side-chat-host.test.tsx b/src/client/components/__tests__/side-chat-host.test.tsx deleted file mode 100644 index 86a80b7d..00000000 --- a/src/client/components/__tests__/side-chat-host.test.tsx +++ /dev/null @@ -1,1583 +0,0 @@ -// @vitest-environment happy-dom - -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; - -import type { CreatedAnnotation } from '@/client/lib/annotation-api.js'; -import { streamSideChatResponse } from '@/client/lib/side-chat-stream.js'; - -import { - PatchListProvider, - usePatchList, - usePatchListState, - type PatchAppliers, -} from '../patch-list-host.js'; -import { PatchListOverlay } from '../patch-list-overlay.js'; -import { SideChatHost, useSideChat, type SideChatPinnableItem } from '../side-chat-host.js'; - -const { mockListAnnotationsForSpecificationRequest } = vi.hoisted(() => ({ - mockListAnnotationsForSpecificationRequest: vi.fn(), -})); - -vi.mock('@/client/lib/side-chat-stream.js', () => ({ - streamSideChatResponse: vi.fn(() => Promise.resolve()), -})); - -// V3.0 card 2: PatchListOverlay reads open reconciliation needs via this hook, -// which depends on TanStack Router context. Stub it here so the side-chat host -// tests can render the overlay without a full router setup. -vi.mock('@/client/routes/specification/$id/-specification-data.js', () => ({ - useSpecificationOpenReconciliationNeeds: () => [], - specificationQueryKeys: { - bundle: (id: string) => ['specification', id, 'bundle'] as const, - entities: (id: string) => ['specification', id, 'entities'] as const, - entitiesProjectWide: (id: string) => ['specification', id, 'entities', 'project-wide'] as const, - reconciliationNeeds: (id: string) => ['specification', id, 'reconciliation-needs'] as const, - }, - invalidateOpenReconciliationNeeds: vi.fn(), -})); - -vi.mock('@/client/lib/annotation-api.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - listAnnotationsForSpecificationRequest: mockListAnnotationsForSpecificationRequest, - }; -}); - -afterEach(() => { - cleanup(); - // Clear persisted side-chat preferences (layout, mode) so a stored 'edit' - // mode from one test doesn't leak into the next test's fresh session and - // invert the toggle behaviour assertions. - if (typeof window !== 'undefined') { - window.localStorage.clear(); - } -}); - -const samplePinnable: SideChatPinnableItem = { - kind: 'decision', - id: 7, - referenceCode: 'D7', - content: 'Use SQLite for local storage.', -}; - -function OpenSideChatButton({ item }: { item: SideChatPinnableItem }) { - const sideChat = useSideChat(); - return ( - - ); -} - -function StageActiveEditPatchButton({ newContent }: { newContent: string }) { - const patchList = usePatchList(); - return ( - - ); -} - -interface AppliersHandle { - appliers: PatchAppliers; - annotateMock: MockInstance; - editMock: MockInstance; - edgeMock: MockInstance; - undoMock: MockInstance; -} - -function makeNoopApplier() { - return vi.fn(() => Promise.resolve({ undo: () => Promise.resolve() })); -} - -function makeAppliers(): AppliersHandle { - const undoMock = vi.fn(() => Promise.resolve()); - const annotateMock = vi.fn(() => Promise.resolve({ undo: undoMock, applied: undefined })); - const editMock = makeNoopApplier(); - const edgeMock = makeNoopApplier(); - return { - annotateMock, - editMock, - edgeMock, - undoMock, - appliers: { - annotate: annotateMock as unknown as PatchAppliers['annotate'], - edit: editMock as unknown as PatchAppliers['edit'], - edge: edgeMock as unknown as PatchAppliers['edge'], - drillDown: makeNoopApplier() as unknown as PatchAppliers['drillDown'], - }, - }; -} - -let consoleErrorSpy: MockInstance; - -beforeEach(() => { - // Suppress expected error logging for promise-rejection tests below. - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mockListAnnotationsForSpecificationRequest.mockReset(); - mockListAnnotationsForSpecificationRequest.mockRejectedValue(new Error('annotation list not stubbed')); -}); - -afterEach(() => { - consoleErrorSpy.mockRestore(); -}); - -describe('SideChatHost edit-mode flow (V2)', () => { - function OpenInEditModeButton({ item }: { item: SideChatPinnableItem }) { - const sideChat = useSideChat(); - return ( - - ); - } - - it('sends mode="edit" in the stream request after setMode("edit")', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - const { appliers } = makeAppliers(); - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-edit')); - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'reword this' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalled()); - const [requestArg] = streamMock.mock.calls.at(-1)!; - expect(requestArg.mode).toBe('edit'); - }); - - it('sends mode="edit" when the message is submitted immediately after toggling Edit', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - const { appliers } = makeAppliers(); - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'reword immediately' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /edit mode/i })); - fireEvent.keyDown(textarea, { key: 'Enter' }); - }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalled()); - const [requestArg] = streamMock.mock.calls.at(-1)!; - expect(requestArg.mode).toBe('edit'); - }); - - it('omits mode in the stream request by default (explore)', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - const { appliers } = makeAppliers(); - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'why?' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalled()); - const [requestArg] = streamMock.mock.calls.at(-1)!; - expect(requestArg.mode).toBeUndefined(); - }); - - it('stages an EditPatch when a patch-proposal event arrives during streaming', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - streamMock.mockImplementationOnce(async (_request, onChunk) => { - onChunk({ type: 'text-delta', delta: "Sure, I'll propose: " }); - onChunk({ - type: 'patch-proposal', - toolCallId: 'call-1', - toolName: 'propose_edit', - input: { newContent: 'Use SQLite for local persistence.', newRationale: 'Terser.' }, - }); - onChunk({ type: 'done' }); - }); - - function StagedEditPatchInspector() { - const state = usePatchListState(); - const editPatches = state.staged.filter((patch) => patch.kind === 'edit'); - return ( -
- {editPatches.map((patch) => ( -
- ))} -
- ); - } - - const { appliers } = makeAppliers(); - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-edit')); - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'reword' } }); - await act(async () => { - fireEvent.keyDown(textarea, { key: 'Enter' }); - }); - - await vi.waitFor(() => expect(screen.queryAllByTestId('staged-edit-row')).toHaveLength(1)); - const row = screen.getByTestId('staged-edit-row'); - expect(row.dataset.anchorKind).toBe('decision'); - expect(row.dataset.anchorId).toBe('7'); - expect(row.dataset.newContent).toBe('Use SQLite for local persistence.'); - expect(row.dataset.newRationale).toBe('Terser.'); - }); - - it('refreshes the pinned-item content shown in the popover after an edit patch applies', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - streamMock.mockImplementationOnce(async (_request, onChunk) => { - onChunk({ type: 'text-delta', delta: 'Proposing.' }); - onChunk({ - type: 'patch-proposal', - toolCallId: 'call-1', - toolName: 'propose_edit', - input: { newContent: 'Refined: SQLite for local persistence.' }, - }); - onChunk({ type: 'done' }); - }); - const { appliers } = makeAppliers(); - // Edit applier resolves successfully so the patch transitions to applied - appliers.edit = vi.fn(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { impact: 'soft', previousContent: 'Use SQLite for local storage.' }, - }), - ) as unknown as PatchAppliers['edit']; - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-edit')); - // Pinned content shows the original snapshot before any edit - expect(screen.getByText(/Use SQLite for local storage\./i)).toBeTruthy(); - - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'refine' } }); - await act(async () => { - fireEvent.keyDown(textarea, { key: 'Enter' }); - }); - - // Patch staged → user clicks Apply → patch applies → applier resolves - await screen.findByRole('button', { name: /^apply( [0-9]+ change)?$/i }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^apply( [0-9]+ change)?$/i })); - }); - - // After apply, pinned content reflects newContent — no need to reopen - await screen.findByText(/Refined: SQLite for local persistence\./i); - expect(screen.queryByText('Use SQLite for local storage.')).toBeNull(); - }); - - it('reverts the pinned-item content shown in the popover after undoing an edit patch', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - streamMock.mockImplementationOnce(async (_request, onChunk) => { - onChunk({ type: 'text-delta', delta: 'Proposing.' }); - onChunk({ - type: 'patch-proposal', - toolCallId: 'call-1', - toolName: 'propose_edit', - input: { newContent: 'Refined: SQLite for local persistence.' }, - }); - onChunk({ type: 'done' }); - }); - const { appliers } = makeAppliers(); - appliers.edit = vi.fn(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { impact: 'soft', previousContent: 'Use SQLite for local storage.' }, - }), - ) as unknown as PatchAppliers['edit']; - - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-edit')); - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'refine' } }); - await act(async () => { - fireEvent.keyDown(textarea, { key: 'Enter' }); - }); - // Card 4 follow-up: Apply buttons exist in both the overlay's - // staged-changes bar and the popover footer; click the first match. - await vi.waitFor(() => - expect(screen.getAllByRole('button', { name: /^apply( [0-9]+ change)?$/i }).length).toBeGreaterThan(0), - ); - await act(async () => { - fireEvent.click(screen.getAllByRole('button', { name: /^apply( [0-9]+ change)?$/i })[0]!); - }); - await screen.findByText(/Refined: SQLite for local persistence\./i); - - // Card 4 follow-up: Undo lives in the saved-toast, - // not in the popover composer footer. Pick the overlay's Undo button. - const overlaySavedToast = screen.getAllByRole('status', { name: /change saved/i })[0]!; - await act(async () => { - fireEvent.click(within(overlaySavedToast).getByRole('button', { name: /^undo$/i })); - }); - - await screen.findByText('Use SQLite for local storage.'); - expect(screen.queryByText(/Refined: SQLite for local persistence\./i)).toBeNull(); - }); - - it('reverts the pinned-item content when Undo is clicked from the overlay saved-toast', async () => { - const { appliers } = makeAppliers(); - appliers.edit = vi.fn(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { impact: 'soft', previousContent: 'Use SQLite for local storage.' }, - }), - ) as unknown as PatchAppliers['edit']; - - render( - - - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByText('stage-active-edit')); - const stagedRegion = screen.getAllByRole('region', { name: /staged changes/i })[0]!; - await act(async () => { - fireEvent.click(within(stagedRegion).getByRole('button', { name: /^apply( [0-9]+ change)?$/i })); - }); - await screen.findByText(/Refined: SQLite for local persistence\./i); - - const overlaySavedToast = screen.getAllByRole('status', { name: /change saved/i })[0]!; - await act(async () => { - fireEvent.click(within(overlaySavedToast).getByRole('button', { name: /^undo$/i })); - }); - - await screen.findByText('Use SQLite for local storage.'); - expect(screen.queryByText(/Refined: SQLite for local persistence\./i)).toBeNull(); - }); - - it('reverts the pinned-item content when Undo is clicked from the overlay staged-changes region', async () => { - const { appliers } = makeAppliers(); - appliers.edit = vi.fn(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { impact: 'soft', previousContent: 'Use SQLite for local storage.' }, - }), - ) as unknown as PatchAppliers['edit']; - - render( - - - - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getAllByText('stage-active-edit')[0]!); - await act(async () => { - fireEvent.click( - within(screen.getAllByRole('region', { name: /staged changes/i })[0]!).getByRole('button', { - name: /^apply( [0-9]+ change)?$/i, - }), - ); - }); - await screen.findByText(/Refined: SQLite for local persistence\./i); - - fireEvent.click(screen.getAllByText('stage-active-edit')[1]!); - const stagedRegion = screen.getAllByRole('region', { name: /staged changes/i })[0]!; - await act(async () => { - fireEvent.click(within(stagedRegion).getByRole('button', { name: /^undo$/i })); - }); - - await screen.findByText('Use SQLite for local storage.'); - expect(screen.queryByText(/Refined: SQLite for local persistence\./i)).toBeNull(); - }); -}); - -describe('SideChatHost annotate flow', () => { - it('clicking Annotate switches the popover into composer mode', () => { - const { appliers } = makeAppliers(); - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - - expect(screen.getByLabelText('Annotation summary')).toBeTruthy(); - expect(screen.getByLabelText('Annotation body')).toBeTruthy(); - }); - - it('staging an annotation auto-applies it (per the D131 user-driven carve-out) and surfaces Undo', async () => { - const { appliers, annotateMock } = makeAppliers(); - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { - target: { value: 'Tighten phrasing' }, - }); - fireEvent.change(screen.getByLabelText('Annotation body'), { - target: { value: 'The current wording is ambiguous.' }, - }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - await screen.findByRole('button', { name: /^undo$/i }); - - expect(screen.queryByLabelText('Annotation summary')).toBeNull(); - expect(screen.queryByText('1 staged annotation')).toBeNull(); - expect(annotateMock).toHaveBeenCalledTimes(1); - }); - - it('passes the trimmed summary + body through to the annotate applier on auto-apply', async () => { - const { appliers, annotateMock } = makeAppliers(); - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - await screen.findByRole('button', { name: /^undo$/i }); - - expect(annotateMock).toHaveBeenCalledTimes(1); - const stagedPatch = annotateMock.mock.calls[0]?.[0] as { kind: string; summary: string; body: string }; - expect(stagedPatch.kind).toBe('annotate'); - expect(stagedPatch.summary).toBe('sum'); - expect(stagedPatch.body).toBe('body'); - }); - - it('Undo after auto-apply invokes the returned undo handle and flips canUndo off', async () => { - const { appliers, undoMock } = makeAppliers(); - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - await screen.findByRole('button', { name: /^undo$/i }); - - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^undo$/i })); - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(undoMock).toHaveBeenCalledTimes(1); - expect(screen.queryByText(/change saved/i)).toBeNull(); - expect(screen.queryByRole('button', { name: /^undo$/i })).toBeNull(); - expect(screen.queryByRole('button', { name: /^apply( [0-9]+ change)?$/i })).toBeNull(); - }); - - it('Apply failure preserves the staged patch and leaves canUndo false', async () => { - const failingAnnotate = vi.fn(() => Promise.reject(new Error('boom'))); - const appliers: PatchAppliers = { - annotate: failingAnnotate as unknown as PatchAppliers['annotate'], - edit: makeNoopApplier() as unknown as PatchAppliers['edit'], - edge: makeNoopApplier() as unknown as PatchAppliers['edge'], - drillDown: makeNoopApplier() as unknown as PatchAppliers['drillDown'], - }; - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(failingAnnotate).toHaveBeenCalledTimes(1); - expect(screen.getByText(/1 pending change/i)).toBeTruthy(); - expect(screen.queryByRole('button', { name: /^undo$/i })).toBeNull(); - expect(screen.getByRole('button', { name: /^apply( [0-9]+ change)?$/i })).toBeTruthy(); - }); - - it('Discard removes a stuck-staged patch (failed auto-apply) from the inline list', async () => { - const failingAnnotate = vi.fn(() => Promise.reject(new Error('boom'))); - const appliers: PatchAppliers = { - annotate: failingAnnotate as unknown as PatchAppliers['annotate'], - edit: makeNoopApplier() as unknown as PatchAppliers['edit'], - edge: makeNoopApplier() as unknown as PatchAppliers['edge'], - drillDown: makeNoopApplier() as unknown as PatchAppliers['drillDown'], - }; - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(screen.getByText(/1 pending change/i)).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: /discard staged change/i })); - - expect(screen.queryByText(/1 pending change/i)).toBeNull(); - }); - - it('inline patch list filters stuck-staged patches to the currently pinned item', async () => { - const failingAnnotate = vi.fn(() => Promise.reject(new Error('boom'))); - const appliers: PatchAppliers = { - annotate: failingAnnotate as unknown as PatchAppliers['annotate'], - edit: makeNoopApplier() as unknown as PatchAppliers['edit'], - edge: makeNoopApplier() as unknown as PatchAppliers['edge'], - drillDown: makeNoopApplier() as unknown as PatchAppliers['drillDown'], - }; - const otherItem: SideChatPinnableItem = { - kind: 'goal', - id: 11, - referenceCode: 'G11', - content: 'Ship V1.2', - }; - - function OpenButtons() { - const sideChat = useSideChat(); - return ( - <> - - - - ); - } - - render( - - - - - , - ); - - // Stage on D7 (auto-apply fails, patch sits in staged on D7's anchor) - fireEvent.click(screen.getByText('open-decision')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'd-sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'd-body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(screen.getByText('d-sum')).toBeTruthy(); - - // Switch to G11 (different anchor); inline list should show no rows for G11. - fireEvent.click(screen.getByText('open-goal')); - expect(screen.queryByText('d-sum')).toBeNull(); - expect(screen.queryByText(/1 pending change/i)).toBeNull(); - - // Switch back to D7; the staged patch reappears. - fireEvent.click(screen.getByText('open-decision')); - expect(screen.getByText('d-sum')).toBeTruthy(); - }); - - it('auto-applies an annotation for the active item without applying a staged edit for another item', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - streamMock.mockImplementationOnce(async (_request, onChunk) => { - onChunk({ - type: 'patch-proposal', - toolCallId: 'call-1', - toolName: 'propose_edit', - input: { newContent: 'Use IndexedDB for local persistence.' }, - }); - onChunk({ type: 'done' }); - }); - const { appliers, annotateMock, editMock } = makeAppliers(); - const otherItem: SideChatPinnableItem = { - kind: 'goal', - id: 11, - referenceCode: 'G11', - content: 'Ship V1.2', - }; - - function OpenButtons() { - const sideChat = useSideChat(); - return ( - <> - - - - ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-decision')); - fireEvent.click(screen.getByRole('button', { name: /edit mode/i })); - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'reword' } }); - await act(async () => { - fireEvent.keyDown(textarea, { key: 'Enter' }); - }); - await screen.findByText('Edit: Use IndexedDB for local persistence.'); - - fireEvent.click(screen.getByText('open-goal')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'g-sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'g-body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - await vi.waitFor(() => expect(annotateMock).toHaveBeenCalledTimes(1)); - expect(editMock).not.toHaveBeenCalled(); - expect(screen.queryByText(/1 pending change/i)).toBeNull(); - - fireEvent.click(screen.getByText('open-decision')); - expect(screen.getByText('Edit: Use IndexedDB for local persistence.')).toBeTruthy(); - }); - - it('shows Undo after applying an edge patch for the active item', async () => { - const { appliers, edgeMock } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - const patchList = usePatchListState(); - const actions = usePatchList(); - return ( - <> - {patchList.staged.length} - - - - ); - } - - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-decision')); - fireEvent.click(screen.getByText('stage-edge')); - await screen.findByText('Edge: D7 depends on G11'); - // Card 4 follow-up: Apply buttons exist in both the overlay's - // staged-changes bar and the popover footer; click the first match. - await act(async () => { - fireEvent.click(screen.getAllByRole('button', { name: /^apply( [0-9]+ change)?$/i })[0]!); - }); - - await vi.waitFor(() => expect(edgeMock).toHaveBeenCalledTimes(1)); - // Undo now lives in saved-toast. - expect(screen.getAllByRole('button', { name: /^undo$/i }).length).toBeGreaterThan(0); - }); - - // Card 4 follow-up: the "Change saved" toast moved out of the popover - // (per pinned item) into the global . The toast no - // longer resets when the user pins a different item — it auto-dismisses - // on its own timer. The previous "does not leak the saved confirmation - // to another pinned item" assertion is obsolete and is intentionally - // omitted; toast lifecycle is exercised in patch-list-overlay.test.tsx. - - it('omits the Annotate button when no PatchListProvider is in scope (host degrades gracefully)', () => { - render( - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - expect(screen.queryByRole('button', { name: /add a note/i })).toBeNull(); - }); -}); - -describe('SideChatHost active cards', () => { - it('exposes activeCardIds and dismissCard via context; pushes ids on apply', async () => { - const { appliers, annotateMock } = makeAppliers(); - annotateMock.mockImplementation(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { id: 101, summary: 'summary', body: 'body' }, - }), - ); - - function Probe() { - const sideChat = useSideChat(); - return ( -
- {sideChat?.activeCardIds.join(',') ?? ''} - -
- ); - } - - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { - target: { value: 's' }, - }); - fireEvent.change(screen.getByLabelText('Annotation body'), { - target: { value: 'b' }, - }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - await screen.findByText('101', { selector: '[data-testid="ids"]' }); - - fireEvent.click(screen.getByRole('button', { name: /^dismiss$/i })); - await screen.findByText('', { selector: '[data-testid="ids"]' }); - }); - - it('does not promote an applied annotation after switching to another item before apply resolves', async () => { - let resolveAnnotate: ((value: { undo: () => Promise; applied: unknown }) => void) | undefined; - const annotateMock = vi.fn( - () => - new Promise<{ undo: () => Promise; applied: unknown }>((resolve) => { - resolveAnnotate = resolve; - }), - ); - const appliers: PatchAppliers = { - annotate: annotateMock as unknown as PatchAppliers['annotate'], - edit: makeNoopApplier() as unknown as PatchAppliers['edit'], - edge: makeNoopApplier() as unknown as PatchAppliers['edge'], - drillDown: makeNoopApplier() as unknown as PatchAppliers['drillDown'], - }; - const otherItem: SideChatPinnableItem = { - kind: 'goal', - id: 22, - referenceCode: 'G22', - content: 'Other item content', - }; - - function Probe() { - const sideChat = useSideChat(); - return ( -
- {sideChat?.activeCardIds.join(',') ?? ''} - - -
- ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-A')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'a-sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'a-body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - await vi.waitFor(() => expect(annotateMock).toHaveBeenCalledTimes(1)); - - fireEvent.click(screen.getByText('open-B')); - await act(async () => { - resolveAnnotate?.({ - undo: () => Promise.resolve(), - applied: { id: 909, summary: 'a-sum', body: 'a-body' }, - }); - }); - - await vi.waitFor(() => expect(screen.getByTestId('ids').textContent).toBe('')); - expect(screen.queryByText('«a-sum»')).toBeNull(); - }); -}); - -describe('SideChatHost thread interleaving', () => { - it('renders an active card chronologically interleaved with messages', async () => { - const { appliers, annotateMock } = makeAppliers(); - annotateMock.mockImplementation(() => - Promise.resolve({ undo: () => Promise.resolve(), applied: { id: 7, summary: 'phrase', body: 'note' } }), - ); - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { - target: { value: 'phrase' }, - }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'note' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - // The card should land in the thread with data-thread-item="card" and contain "phrase" - await screen.findByText('«phrase»', { selector: '[data-thread-item="card"] *' }); - }); -}); - -describe('SideChatHost dismiss/reopen state isolation', () => { - it('keeps the existing thread when reopening the already-active item', async () => { - const { appliers } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - return ( - - ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open')); - const textarea = await screen.findByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'keep this thread' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await screen.findByText('keep this thread'); - fireEvent.click(screen.getByText('open')); - - expect(screen.getByText('keep this thread')).toBeTruthy(); - }); - - it('clears active cards when the side-chat is dismissed and reopened for the same item', async () => { - const { appliers, annotateMock } = makeAppliers(); - annotateMock.mockImplementation(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { id: 201, summary: 's', body: 'b' }, - }), - ); - - function Probe() { - const sideChat = useSideChat(); - return ( -
- {sideChat?.activeCardIds.join(',') ?? ''} - -
- ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 's' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'b' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - await screen.findByText('201', { selector: '[data-testid="ids"]' }); - - // Dismiss via the popover's close affordance. - fireEvent.click(screen.getByRole('button', { name: /close side-chat/i })); - await screen.findByText('', { selector: '[data-testid="ids"]' }); - - // Reopen for the same item; cards should remain cleared. - fireEvent.click(screen.getByText('open')); - expect(screen.getByTestId('ids').textContent).toBe(''); - }); - - it('reopens the same item immediately after dismissing the side-chat', async () => { - const { appliers } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - return ( - - ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open')); - await screen.findByLabelText(/^message$/i); - - fireEvent.click(screen.getByRole('button', { name: /close side-chat/i })); - fireEvent.click(screen.getByText('open')); - - expect(await screen.findByLabelText(/^message$/i)).toBeTruthy(); - }); - - it('clears active cards when switching the side-chat to a different item', async () => { - const { appliers, annotateMock } = makeAppliers(); - annotateMock.mockImplementation((patch) => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { id: 301, summary: patch.summary, body: patch.body }, - }), - ); - - const otherItem: SideChatPinnableItem = { - kind: 'goal', - id: 22, - referenceCode: 'G22', - content: 'Other item content', - }; - - function Probe() { - const sideChat = useSideChat(); - return ( -
- {sideChat?.activeCardIds.join(',') ?? ''} - - -
- ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-A')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'a-sum' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'a-body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - await screen.findByText('301', { selector: '[data-testid="ids"]' }); - - // Switch to item B; cards from A must not leak. - fireEvent.click(screen.getByText('open-B')); - expect(screen.getByTestId('ids').textContent).toBe(''); - }); -}); - -describe('SideChatHost span hints', () => { - it('forwards openWithSpanHint and includes spanHint in the next stream request', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - - const { appliers } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - return ( - - ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-with-hint')); - const textarea = await screen.findByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'tell me more' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => { - expect(streamMock).toHaveBeenCalled(); - }); - const [requestArg] = streamMock.mock.calls[0]; - expect(requestArg).toMatchObject({ spanHint: 'highlighted phrase' }); - }); - - it('clears spanHint after the first message is sent', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - - const { appliers } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - return ( - - ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-with-hint')); - const textarea = await screen.findByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'first' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalledTimes(1)); - - fireEvent.change(textarea, { target: { value: 'second' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalledTimes(2)); - const [secondRequest] = streamMock.mock.calls[1]; - expect(secondRequest).not.toHaveProperty('spanHint'); - }); -}); - -describe('SideChatHost active annotations payload', () => { - it('drops active cards after undo when the refreshed annotation list no longer contains them', async () => { - const createdAnnotation: CreatedAnnotation = { - id: 401, - specification_id: 1, - knowledge_item_id: samplePinnable.id, - summary: 'undo me', - body: 'stale body', - selection_start: null, - selection_end: null, - created_at: '2026-05-05T00:00:00.000Z', - }; - let annotationList: CreatedAnnotation[] = []; - mockListAnnotationsForSpecificationRequest.mockImplementation(() => Promise.resolve(annotationList)); - - const { appliers, annotateMock } = makeAppliers(); - annotateMock.mockImplementation(() => { - annotationList = [createdAnnotation]; - return Promise.resolve({ - undo: () => { - annotationList = []; - return Promise.resolve(); - }, - applied: { - id: createdAnnotation.id, - summary: createdAnnotation.summary, - body: createdAnnotation.body, - }, - }); - }); - - function Probe() { - const sideChat = useSideChat(); - return ( -
- {sideChat?.activeCardIds.join(',') ?? ''} - -
- ); - } - - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'undo me' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'stale body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - await screen.findByText('401', { selector: '[data-testid="ids"]' }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^undo$/i })); - }); - - await vi.waitFor(() => expect(screen.getByTestId('ids').textContent).toBe('')); - }); - - it('uses each active card reference code instead of the current pinned item reference code', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - - const { appliers, annotateMock } = makeAppliers(); - annotateMock.mockImplementation((patch) => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { id: 501, summary: patch.summary, body: patch.body }, - }), - ); - - const renumberedPinnable: SideChatPinnableItem = { - ...samplePinnable, - referenceCode: 'D99', - }; - - function Probe() { - const sideChat = useSideChat(); - return ( -
- - -
- ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-original')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'sticky ref' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - await screen.findByText('«sticky ref»', { selector: '[data-thread-item="card"] *' }); - fireEvent.click(screen.getByText('open-renumbered')); - - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'go' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalled()); - const [requestArg] = streamMock.mock.calls.at(-1)!; - expect(requestArg.activeAnnotations).toEqual([ - { referenceCode: 'D7', snapshot: 'sticky ref', body: 'body' }, - ]); - }); - - it('sends only the 8 most-recent active annotations in the stream payload, with older ones marked not in context', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - - const { appliers, annotateMock } = makeAppliers(); - let nextId = 1; - annotateMock.mockImplementation((patch) => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { id: nextId++, summary: patch.summary, body: patch.body }, - }), - ); - - function PromoteAll() { - const sideChat = useSideChat(); - const ids = sideChat?.activeCardIds ?? []; - return {ids.length}; - } - - render( - - - - - - , - ); - - fireEvent.click(screen.getByText('open-side-chat')); - - // Stage 10 annotations sequentially via the form - for (let i = 0; i < 10; i++) { - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { - target: { value: `phrase ${i + 1}` }, - }); - fireEvent.change(screen.getByLabelText('Annotation body'), { - target: { value: `body ${i + 1}` }, - }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - } - - await screen.findByText('10', { selector: '[data-testid="card-count"]' }); - - // Send a chat message — the request should include exactly 8 activeAnnotations. - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'go' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalled()); - const [requestArg] = streamMock.mock.calls.at(-1)!; - expect(requestArg.activeAnnotations).toHaveLength(8); - // Most recent 8 means phrases 3..10 (oldest 1, 2 dropped). - expect(requestArg.activeAnnotations![0].snapshot).toBe('phrase 3'); - expect(requestArg.activeAnnotations![7].snapshot).toBe('phrase 10'); - }); - - it('does not promote id-only applied annotation metadata into active chat context', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - - const { appliers, annotateMock } = makeAppliers(); - annotateMock.mockImplementation(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { id: 601 }, - }), - ); - - function Probe() { - const sideChat = useSideChat(); - return ( -
- {sideChat?.activeCardIds.join(',') ?? ''} - -
- ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open')); - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'local summary' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'local body' } }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - }); - - await vi.waitFor(() => expect(screen.getByTestId('ids').textContent).toBe('')); - - const textarea = screen.getByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'go' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalled()); - const [requestArg] = streamMock.mock.calls.at(-1)!; - expect(requestArg).not.toHaveProperty('activeAnnotations'); - }); -}); - -describe('SideChatHost span-hint chip', () => { - it('renders a span-hint chip in the panel when openWithSpanHint is called', async () => { - const { appliers } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - return ( - - ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-with-hint')); - const chip = await screen.findByText(/household income/); - expect(chip.closest('[data-span-hint-chip]')).not.toBeNull(); - }); - - it('clearing the chip removes pendingSpanHint and the next message has no spanHint in payload', async () => { - const streamMock = vi.mocked(streamSideChatResponse); - streamMock.mockClear(); - - const { appliers } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - return ( - - ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open-with-hint')); - await screen.findByText(/phrase/); - - // Click the dismiss button on the chip - fireEvent.click(screen.getByRole('button', { name: /clear span hint/i })); - - // Chip should disappear - expect(screen.queryByText(/«phrase»/)).toBeNull(); - - // Next message should not include spanHint - const textarea = await screen.findByLabelText(/^message$/i); - fireEvent.change(textarea, { target: { value: 'go' } }); - fireEvent.keyDown(textarea, { key: 'Enter' }); - - await vi.waitFor(() => expect(streamMock).toHaveBeenCalled()); - const [requestArg] = streamMock.mock.calls.at(-1)!; - expect(requestArg).not.toHaveProperty('spanHint'); - }); -}); - -describe('SideChatHost promote annotation', () => { - it('promoteAnnotation pushes the annotation onto activeCardIds', async () => { - const inertAnnotation: CreatedAnnotation = { - id: 555, - specification_id: 1, - knowledge_item_id: samplePinnable.id, - summary: 'inert summary', - body: 'inert body', - selection_start: null, - selection_end: null, - created_at: new Date().toISOString(), - }; - mockListAnnotationsForSpecificationRequest.mockReset(); - mockListAnnotationsForSpecificationRequest.mockResolvedValue([inertAnnotation]); - - const { appliers } = makeAppliers(); - - function Probe() { - const sideChat = useSideChat(); - return ( -
- {sideChat?.activeCardIds.join(',') ?? ''} - - -
- ); - } - - render( - - - - - , - ); - - fireEvent.click(screen.getByText('open')); - // Wait for the annotations list effect to populate provider state. - await vi.waitFor(() => - expect(screen.getByRole('button', { name: /show existing notes/i }).textContent).toContain('1'), - ); - - await act(async () => { - fireEvent.click(screen.getByText('promote')); - }); - - await screen.findByText('555', { selector: '[data-testid="ids"]' }); - }); -}); diff --git a/src/client/components/__tests__/side-chat-popover.test.tsx b/src/client/components/__tests__/side-chat-popover.test.tsx deleted file mode 100644 index 2192598f..00000000 --- a/src/client/components/__tests__/side-chat-popover.test.tsx +++ /dev/null @@ -1,917 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { SideChatPopover, type SideChatMessage, type SideChatThreadItem } from '../side-chat-popover.js'; - -function toThreadItems(messages: readonly SideChatMessage[]): readonly SideChatThreadItem[] { - return messages.map((message, index) => ({ - kind: 'message' as const, - id: `m-${index}`, - message, - timestamp: index, - })); -} - -afterEach(() => { - cleanup(); -}); - -const baseItem = { referenceCode: 'D12', content: 'Use SQLite for the local store.' }; - -describe('SideChatPopover', () => { - it('renders the pinned item referenceCode and content', () => { - render( {}} />); - - expect(screen.getByText('D12')).toBeTruthy(); - expect(screen.getByText('Use SQLite for the local store.')).toBeTruthy(); - }); - - it('renders an empty message list area', () => { - render( {}} />); - - const list = screen.getByRole('log', { name: /side[- ]chat messages/i }); - expect(list.children.length).toBe(0); - }); - - it('disables the send button when the input is empty', () => { - render( {}} />); - - const send = screen.getByRole('button', { name: /send/i }) as HTMLButtonElement; - expect(send.disabled).toBe(true); - }); - - it('enables the send button when the input has trimmed content', () => { - render( {}} />); - - fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Why SQLite?' } }); - const send = screen.getByRole('button', { name: /send/i }) as HTMLButtonElement; - expect(send.disabled).toBe(false); - }); - - it('keeps the send button disabled when input is whitespace-only', () => { - render( {}} />); - - fireEvent.change(screen.getByLabelText('Message'), { target: { value: ' ' } }); - const send = screen.getByRole('button', { name: /send/i }) as HTMLButtonElement; - expect(send.disabled).toBe(true); - }); - - it('fires onDismiss when the close button is clicked', () => { - const onDismiss = vi.fn(); - render(); - - fireEvent.click(screen.getByRole('button', { name: /close side[- ]chat/i })); - - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it('fires onDismiss when Esc is pressed', () => { - const onDismiss = vi.fn(); - render(); - - fireEvent.keyDown(document, { key: 'Escape' }); - - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it('does not fire onDismiss when the user clicks outside the popover', () => { - const onDismiss = vi.fn(); - render( -
- - -
, - ); - - fireEvent.mouseDown(screen.getByText('outside')); - - expect(onDismiss).not.toHaveBeenCalled(); - }); - - it('does not fire onDismiss for clicks inside the popover', () => { - const onDismiss = vi.fn(); - render(); - - fireEvent.mouseDown(screen.getByRole('dialog')); - - expect(onDismiss).not.toHaveBeenCalled(); - }); - - it('moves keyboard focus to the message input when the popover mounts', () => { - render( {}} />); - - expect(document.activeElement).toBe(screen.getByLabelText('Message')); - }); - - it('exposes the popover surface as a dialog with an accessible name', () => { - render( {}} />); - - expect(screen.getByRole('dialog', { name: /side[- ]chat/i })).toBeTruthy(); - }); - - describe('messages, streaming, and submit', () => { - it('renders user and assistant messages from the threadItems prop in order', () => { - render( - {}} - threadItems={toThreadItems([ - { role: 'user', text: 'Why SQLite?' }, - { role: 'assistant', text: 'It keeps the runtime local-first.' }, - ])} - />, - ); - - const log = screen.getByRole('log', { name: /side[- ]chat messages/i }); - const items = log.querySelectorAll('[data-message-role]'); - expect(items).toHaveLength(2); - expect(items[0].getAttribute('data-message-role')).toBe('user'); - expect(items[0].textContent).toContain('Why SQLite?'); - expect(items[1].getAttribute('data-message-role')).toBe('assistant'); - expect(items[1].textContent).toContain('It keeps the runtime local-first.'); - }); - - it('marks a message with pending: true so the in-flight assistant turn renders as such', () => { - render( - {}} - threadItems={toThreadItems([ - { role: 'user', text: 'Why?' }, - { role: 'assistant', text: 'It keeps', pending: true }, - ])} - />, - ); - - const log = screen.getByRole('log', { name: /side[- ]chat messages/i }); - const items = log.querySelectorAll('[data-message-role]'); - expect(items).toHaveLength(2); - expect(items[1].getAttribute('data-message-role')).toBe('assistant'); - expect(items[1].getAttribute('data-message-pending')).toBe('true'); - expect(items[1].textContent).toContain('It keeps'); - }); - - it('renders no pending row when no message carries pending: true', () => { - render( - {}} - threadItems={toThreadItems([ - { role: 'user', text: 'Why?' }, - { role: 'assistant', text: 'It depends.' }, - ])} - />, - ); - - const log = screen.getByRole('log', { name: /side[- ]chat messages/i }); - const items = log.querySelectorAll('[data-message-role]'); - expect(items).toHaveLength(2); - expect(items[1].getAttribute('data-message-pending')).not.toBe('true'); - }); - - it('calls onSubmit with the trimmed input value when the send button is clicked', () => { - const onSubmit = vi.fn(); - render( {}} onSubmit={onSubmit} />); - - fireEvent.change(screen.getByLabelText('Message'), { target: { value: ' Why SQLite? ' } }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith('Why SQLite?'); - }); - - it('calls onSubmit when Enter is pressed in the message input', () => { - const onSubmit = vi.fn(); - render( {}} onSubmit={onSubmit} />); - - const input = screen.getByLabelText('Message'); - fireEvent.change(input, { target: { value: 'Hello' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith('Hello'); - }); - - it('does not call onSubmit when Shift+Enter is pressed (newline allowed in textarea)', () => { - const onSubmit = vi.fn(); - render( {}} onSubmit={onSubmit} />); - - const input = screen.getByLabelText('Message'); - fireEvent.change(input, { target: { value: 'Hello' } }); - fireEvent.keyDown(input, { key: 'Enter', shiftKey: true }); - - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('clears the message input after a successful submit', () => { - render( {}} onSubmit={() => {}} />); - - const input = screen.getByLabelText('Message') as HTMLTextAreaElement; - fireEvent.change(input, { target: { value: 'Hello' } }); - fireEvent.click(screen.getByRole('button', { name: /send/i })); - - expect(input.value).toBe(''); - }); - - it('renders error-flagged messages with a distinct treatment', () => { - render( - {}} - threadItems={toThreadItems([ - { role: 'user', text: 'Why?' }, - { role: 'assistant', text: 'Something went wrong — try again.', error: true }, - ])} - />, - ); - - const log = screen.getByRole('log', { name: /side[- ]chat messages/i }); - const items = log.querySelectorAll('[data-message-role]'); - expect(items).toHaveLength(2); - expect(items[1].getAttribute('data-message-error')).toBe('true'); - expect(items[1].textContent).toContain('Something went wrong'); - }); - - it('does not mark non-error messages with the error attribute', () => { - render( - {}} - threadItems={toThreadItems([ - { role: 'user', text: 'Why?' }, - { role: 'assistant', text: 'It depends.' }, - ])} - />, - ); - - const log = screen.getByRole('log', { name: /side[- ]chat messages/i }); - const items = log.querySelectorAll('[data-message-role]'); - expect(items[1].getAttribute('data-message-error')).not.toBe('true'); - }); - - it('disables the send button while a submission is in-flight (last message is pending)', () => { - render( - {}} - threadItems={toThreadItems([ - { role: 'user', text: 'Why?' }, - { role: 'assistant', text: '', pending: true }, - ])} - />, - ); - fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello' } }); - const send = screen.getByRole('button', { name: /send/i }) as HTMLButtonElement; - expect(send.disabled).toBe(true); - }); - - it('does not call onSubmit when the input is empty', () => { - const onSubmit = vi.fn(); - render( {}} onSubmit={onSubmit} />); - - const send = screen.getByRole('button', { name: /send/i }); - fireEvent.click(send); - expect(onSubmit).not.toHaveBeenCalled(); - }); - }); - - describe('annotate composer', () => { - it('renders the Note button when onAnnotateRequest is provided', () => { - render( - {}} - onAnnotateRequest={() => {}} - onAnnotateCancel={() => {}} - onAnnotateSubmit={() => {}} - />, - ); - - expect(screen.getByRole('button', { name: /add a note/i })).toBeTruthy(); - }); - - it('does not render the Note button without onAnnotateRequest', () => { - render( {}} />); - - expect(screen.queryByRole('button', { name: /add a note/i })).toBeNull(); - }); - - it('clicking Note fires onAnnotateRequest', () => { - const onAnnotateRequest = vi.fn(); - render( - {}} onAnnotateRequest={onAnnotateRequest} />, - ); - - fireEvent.click(screen.getByRole('button', { name: /add a note/i })); - expect(onAnnotateRequest).toHaveBeenCalledTimes(1); - }); - - it('disables the Note button while a stream is in flight', () => { - render( - {}} - threadItems={toThreadItems([ - { role: 'user', text: 'Q' }, - { role: 'assistant', text: '', pending: true }, - ])} - onAnnotateRequest={() => {}} - />, - ); - - const button = screen.getByRole('button', { name: /add a note/i }) as HTMLButtonElement; - expect(button.disabled).toBe(true); - }); - - it('annotateMode replaces the chat input with the composer form', () => { - render( - {}} - annotateMode - onAnnotateRequest={() => {}} - onAnnotateCancel={() => {}} - onAnnotateSubmit={() => {}} - />, - ); - - expect(screen.queryByLabelText('Message')).toBeNull(); - expect(screen.getByLabelText('Annotation summary')).toBeTruthy(); - expect(screen.getByLabelText('Annotation body')).toBeTruthy(); - }); - - it('Save button is disabled until both summary and body are non-empty', () => { - render( - {}} - annotateMode - onAnnotateRequest={() => {}} - onAnnotateCancel={() => {}} - onAnnotateSubmit={() => {}} - />, - ); - - const saveButton = screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement; - expect(saveButton.disabled).toBe(true); - - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: 'sum' } }); - expect(saveButton.disabled).toBe(true); - - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: 'body' } }); - expect(saveButton.disabled).toBe(false); - }); - - it('Save submits trimmed summary + body via onAnnotateSubmit', () => { - const onAnnotateSubmit = vi.fn(); - render( - {}} - annotateMode - onAnnotateRequest={() => {}} - onAnnotateCancel={() => {}} - onAnnotateSubmit={onAnnotateSubmit} - />, - ); - - fireEvent.change(screen.getByLabelText('Annotation summary'), { target: { value: ' sum ' } }); - fireEvent.change(screen.getByLabelText('Annotation body'), { target: { value: ' body ' } }); - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - - expect(onAnnotateSubmit).toHaveBeenCalledWith('sum', 'body'); - }); - - it('Cancel fires onAnnotateCancel', () => { - const onAnnotateCancel = vi.fn(); - render( - {}} - annotateMode - onAnnotateRequest={() => {}} - onAnnotateCancel={onAnnotateCancel} - onAnnotateSubmit={() => {}} - />, - ); - - fireEvent.click(screen.getByRole('button', { name: /cancel/i })); - expect(onAnnotateCancel).toHaveBeenCalledTimes(1); - }); - - it('Esc cancels the composer instead of dismissing the popover when annotateMode is on', () => { - const onDismiss = vi.fn(); - const onAnnotateCancel = vi.fn(); - render( - {}} - onAnnotateCancel={onAnnotateCancel} - onAnnotateSubmit={() => {}} - />, - ); - - fireEvent.keyDown(document, { key: 'Escape' }); - expect(onAnnotateCancel).toHaveBeenCalledTimes(1); - expect(onDismiss).not.toHaveBeenCalled(); - }); - }); - - describe('apply lifecycle (saving / saved / stuck)', () => { - it('does not render any inline status when there are no staged patches and no completed batch', () => { - render( {}} />); - expect(screen.queryByRole('region', { name: /staged annotations/i })).toBeNull(); - expect(screen.queryByRole('status', { name: /change saved/i })).toBeNull(); - expect(screen.queryByText(/saving change/i)).toBeNull(); - }); - - it('shows the "saving change…" status inline while isApplying with staged patches (Apply disabled)', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'annotate', summary: 'note' }]} - isApplying - onApply={() => {}} - />, - ); - - expect(screen.getByText(/saving change/i)).toBeTruthy(); - expect(screen.getByRole('region', { name: /staged changes/i })).toBeTruthy(); - const applyBtn = screen.getByRole('button', { name: /apply 1 change/i }) as HTMLButtonElement; - expect(applyBtn.disabled).toBe(true); - }); - - // Card 4 follow-up: "Change saved" toast moved out of the side-chat - // composer into (mounted in the specification layout - // route), so the popover no longer surfaces the saved confirmation. Toast - // lifecycle is exercised in patch-list-overlay.test.tsx. - - it('renders the staging panel only when staged>0 and not currently applying (i.e., a stuck/failed batch)', () => { - render( - {}} - stagedPatches={[ - { id: 'p1', kind: 'annotate', summary: 'first note' }, - { id: 'p2', kind: 'annotate', summary: 'second note' }, - ]} - />, - ); - - expect(screen.getByText('first note')).toBeTruthy(); - expect(screen.getByText('second note')).toBeTruthy(); - expect(screen.getByText(/2 pending changes/i)).toBeTruthy(); - }); - - it('Discard button on a stuck patch fires onDiscardPatch', () => { - const onDiscardPatch = vi.fn(); - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'annotate', summary: 'note' }]} - onDiscardPatch={onDiscardPatch} - />, - ); - - fireEvent.click(screen.getByRole('button', { name: /discard staged change/i })); - expect(onDiscardPatch).toHaveBeenCalledWith('p1'); - }); - - it('Apply button on a staged patch fires onApply', () => { - const onApply = vi.fn(); - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'annotate', summary: 'note' }]} - onApply={onApply} - />, - ); - - fireEvent.click(screen.getByRole('button', { name: /apply 1 change/i })); - expect(onApply).toHaveBeenCalledTimes(1); - }); - - it('shows Undo in the staging panel when canUndo is true and staged is non-empty (mixed state)', () => { - const onUndo = vi.fn(); - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'annotate', summary: 'note' }]} - onUndo={onUndo} - canUndo - />, - ); - - fireEvent.click(screen.getByRole('button', { name: /undo last change/i })); - expect(onUndo).toHaveBeenCalledTimes(1); - }); - }); - - describe('notes drawer header', () => { - it('shows a sticky "Notes (N)" header inside the drawer with a close button that collapses it', () => { - render( - {}} - existingAnnotations={[ - { id: 1, summary: 'first', body: '' }, - { id: 2, summary: 'second', body: '' }, - ]} - />, - ); - - // Drawer is collapsed initially — header is not visible. - expect(screen.queryByRole('button', { name: /hide notes/i })).toBeNull(); - - // Open the drawer via the toggle button next to the action row. - fireEvent.click(screen.getByRole('button', { name: /show existing notes/i })); - - // Sticky header inside the drawer renders "Notes (2)" plus a close button. - const closeButton = screen.getByRole('button', { name: /hide notes/i }); - expect(closeButton).toBeTruthy(); - expect(closeButton.parentElement?.textContent).toContain('Notes (2)'); - - // Clicking the × in the header collapses the drawer (close button disappears). - fireEvent.click(closeButton); - expect(screen.queryByRole('button', { name: /hide notes/i })).toBeNull(); - }); - }); - - describe('notes drawer promote-from-drawer affordance', () => { - it('clicking the + button on a drawer item fires onPromoteAnnotation with the right id', () => { - const onPromoteAnnotation = vi.fn(); - render( - {}} - existingAnnotations={[ - { id: 11, summary: 'first', body: '' }, - { id: 22, summary: 'second', body: '' }, - ]} - onPromoteAnnotation={onPromoteAnnotation} - />, - ); - - // Open the drawer. - fireEvent.click(screen.getByRole('button', { name: /show existing notes/i })); - - // Click the + button on the first item. - fireEvent.click(screen.getByRole('button', { name: /add first to context/i })); - expect(onPromoteAnnotation).toHaveBeenCalledTimes(1); - expect(onPromoteAnnotation).toHaveBeenCalledWith(11); - }); - - it('renders the in-context indicator (no + button) when activeAnnotationIds includes the id', () => { - const onPromoteAnnotation = vi.fn(); - render( - {}} - existingAnnotations={[ - { id: 11, summary: 'first', body: '' }, - { id: 22, summary: 'second', body: '' }, - ]} - activeAnnotationIds={[11]} - onPromoteAnnotation={onPromoteAnnotation} - />, - ); - - fireEvent.click(screen.getByRole('button', { name: /show existing notes/i })); - - // 'first' is already in context — no + button for it; the second item still has one. - expect(screen.queryByRole('button', { name: /add first to context/i })).toBeNull(); - expect(screen.getByRole('button', { name: /add second to context/i })).toBeTruthy(); - - // The "Already in chat context" indicator appears on the first row. - const firstRow = screen.getByText('first').closest('[data-annotation-id]') as HTMLElement | null; - expect(firstRow).not.toBeNull(); - expect(firstRow!.querySelector('[title="Already in chat context"]')).not.toBeNull(); - }); - }); - - // Card 4 follow-up: saved-toast lifecycle moved to . - // See patch-list-overlay.test.tsx for the canonical lifecycle suite. -}); - -describe('SideChatPopover — impact tier chip on edit patches (V2 §4.1)', () => { - it('renders a Soft impact chip on staged edit patches with impact="soft"', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'edit', summary: 'Edit: rephrase', impact: 'soft' }]} - />, - ); - const chip = screen.getByLabelText(/soft impact/i); - expect(chip.getAttribute('data-impact')).toBe('soft'); - expect(chip.textContent).toMatch(/soft impact/i); - }); - - it('renders a Hard impact chip on staged edit patches with impact="hard"', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'edit', summary: 'Edit: rephrase', impact: 'hard' }]} - />, - ); - const chip = screen.getByLabelText(/hard impact — v3/i); - expect(chip.getAttribute('data-impact')).toBe('hard'); - }); - - it('renders a No impact chip on staged edit patches with impact="none"', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'edit', summary: 'Edit: rephrase', impact: 'none' }]} - />, - ); - const chip = screen.getByLabelText(/no impact/i); - expect(chip.getAttribute('data-impact')).toBe('none'); - }); - - it('does not render an impact chip when the staged patch is not an edit', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'annotate', summary: 'note' }]} - />, - ); - expect(screen.queryByLabelText(/impact/i)).toBeNull(); - }); - - it('does not render an impact chip when impact is omitted on an edit patch', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'edit', summary: 'Edit: rephrase' }]} - />, - ); - expect(screen.queryByLabelText(/impact/i)).toBeNull(); - }); -}); - -describe('SideChatPopover — Edit-mode toggle (V2)', () => { - it('keeps the Edit button disabled when onModeChange is not provided', () => { - render( {}} />); - const edit = screen.getByRole('button', { name: /edit unavailable/i }) as HTMLButtonElement; - expect(edit.disabled).toBe(true); - }); - - it('explains that disabled Edit is unavailable in the current context', () => { - render( {}} />); - const edit = screen.getByRole('button', { name: /edit unavailable/i }); - expect(edit.getAttribute('title')).toBe('Edit unavailable in this context'); - }); - - it('enables the Edit button when onModeChange is provided', () => { - render( {}} onModeChange={() => {}} />); - const edit = screen.getByRole('button', { name: /edit/i }) as HTMLButtonElement; - expect(edit.disabled).toBe(false); - }); - - it('clicking Edit when mode is "explore" calls onModeChange("edit")', () => { - const onModeChange = vi.fn(); - render( - {}} - mode="explore" - onModeChange={onModeChange} - />, - ); - fireEvent.click(screen.getByRole('button', { name: /edit/i })); - expect(onModeChange).toHaveBeenCalledWith('edit'); - }); - - it('clicking Edit when mode is "edit" calls onModeChange("explore") (toggle off)', () => { - const onModeChange = vi.fn(); - render( - {}} mode="edit" onModeChange={onModeChange} />, - ); - fireEvent.click(screen.getByRole('button', { name: /edit/i })); - expect(onModeChange).toHaveBeenCalledWith('explore'); - }); - - it('marks the Edit button as pressed when mode is "edit"', () => { - render( - {}} mode="edit" onModeChange={() => {}} />, - ); - const edit = screen.getByRole('button', { name: /edit/i }); - expect(edit.getAttribute('aria-pressed')).toBe('true'); - }); - - it('marks the Edit button as not pressed when mode is "explore"', () => { - render( - {}} mode="explore" onModeChange={() => {}} />, - ); - const edit = screen.getByRole('button', { name: /edit/i }); - expect(edit.getAttribute('aria-pressed')).toBe('false'); - }); -}); - -describe('SideChatPopover — staged-edit diff popover (Card 4 polish)', () => { - it('renders a "view diff" chip on edit patches that carry currentContent and newContent', () => { - render( - {}} - stagedPatches={[ - { - id: 'p1', - kind: 'edit', - summary: 'Edit: rephrase', - currentContent: 'Use SQLite for the local store.', - newContent: 'Use Postgres for the local store.', - }, - ]} - />, - ); - const row = document.querySelector('[data-staged-patch-id="p1"]'); - expect(row).not.toBeNull(); - expect(row!.querySelector('[data-view-diff-chip]')).not.toBeNull(); - // The inline
expander has been removed. - expect(row!.querySelector('details')).toBeNull(); - }); - - it('clicking the "view diff" chip opens the DiffPopover with removed/added spans', () => { - render( - {}} - stagedPatches={[ - { - id: 'p1', - kind: 'edit', - summary: 'Edit: rephrase', - currentContent: 'Use SQLite for the local store.', - newContent: 'Use Postgres for the local store.', - }, - ]} - />, - ); - // Popover starts closed. - expect(document.querySelector('[data-diff-popover]')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: /view diff for edit: rephrase/i })); - const popover = document.querySelector('[data-diff-popover]'); - expect(popover).not.toBeNull(); - const removed = popover!.querySelectorAll('[data-diff-kind="removed"]'); - const added = popover!.querySelectorAll('[data-diff-kind="added"]'); - expect(removed.length).toBeGreaterThan(0); - expect(added.length).toBeGreaterThan(0); - expect(Array.from(removed).some((node) => node.textContent?.includes('SQLite'))).toBe(true); - expect(Array.from(added).some((node) => node.textContent?.includes('Postgres'))).toBe(true); - }); - - it('does not render a view-diff chip when the edit patch lacks currentContent or newContent', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'edit', summary: 'Edit: rephrase' }]} - />, - ); - const row = document.querySelector('[data-staged-patch-id="p1"]'); - expect(row).not.toBeNull(); - expect(row!.querySelector('[data-view-diff-chip]')).toBeNull(); - expect(row!.textContent).toContain('Edit: rephrase'); - }); - - it('does not render a view-diff chip when before and after content are equal', () => { - render( - {}} - stagedPatches={[ - { - id: 'p1', - kind: 'edit', - summary: 'Edit: rephrase', - currentContent: 'same content', - newContent: 'same content', - }, - ]} - />, - ); - const row = document.querySelector('[data-staged-patch-id="p1"]'); - expect(row!.querySelector('[data-view-diff-chip]')).toBeNull(); - }); - - it('does not render a view-diff chip on non-edit staged patches', () => { - render( - {}} - stagedPatches={[{ id: 'p1', kind: 'annotate', summary: 'Note about C1' }]} - />, - ); - const row = document.querySelector('[data-staged-patch-id="p1"]'); - expect(row!.querySelector('[data-view-diff-chip]')).toBeNull(); - }); - - it('renders a kind chip on every staged patch row regardless of kind', () => { - render( - {}} - stagedPatches={[ - { id: 'a', kind: 'annotate', summary: 'note' }, - { id: 'b', kind: 'edit', summary: 'edit' }, - { id: 'c', kind: 'edge', summary: 'edge' }, - { id: 'd', kind: 'drill-down', summary: 'drill' }, - ]} - />, - ); - expect(document.querySelector('[data-staged-patch-id="a"] [data-kind-chip="annotate"]')).not.toBeNull(); - expect(document.querySelector('[data-staged-patch-id="b"] [data-kind-chip="edit"]')).not.toBeNull(); - expect(document.querySelector('[data-staged-patch-id="c"] [data-kind-chip="edge"]')).not.toBeNull(); - expect(document.querySelector('[data-staged-patch-id="d"] [data-kind-chip="drill-down"]')).not.toBeNull(); - }); -}); - -describe('SideChatPopover — Card 4 vocabulary + chrome polish', () => { - it('moves the Note button into the input card next to the attach button', () => { - const { container } = render( - {}} onAnnotateRequest={() => {}} />, - ); - const note = container.querySelector('[aria-label="Add a note"]') as HTMLElement; - const attach = container.querySelector('[aria-label="Attach (coming soon)"]') as HTMLElement; - expect(note).not.toBeNull(); - expect(attach).not.toBeNull(); - // Both share the same parent (the input-card left action row). - expect(note.parentElement).toBe(attach.parentElement); - }); - - it('renders the Edit-mode strip below the input card with an Off / Edit on toggle pill', () => { - const { container, rerender } = render( - {}} mode="explore" onModeChange={() => {}} />, - ); - const strip = container.querySelector('[data-edit-mode-strip]'); - expect(strip).not.toBeNull(); - expect(strip!.textContent).toContain('Edit mode'); - expect(strip!.textContent).toContain('Off'); - - rerender( - {}} mode="edit" onModeChange={() => {}} />, - ); - const stripActive = container.querySelector('[data-edit-mode-strip]'); - expect(stripActive!.textContent).toContain('Edit on'); - }); - - it('input placeholder swaps to "Suggest an edit…" when mode is "edit"', () => { - const { rerender } = render( - {}} mode="explore" onModeChange={() => {}} />, - ); - const explore = screen.getByLabelText('Message') as HTMLTextAreaElement; - expect(explore.placeholder).toMatch(/ask me anything/i); - rerender( - {}} mode="edit" onModeChange={() => {}} />, - ); - const edit = screen.getByLabelText('Message') as HTMLTextAreaElement; - expect(edit.placeholder).toMatch(/suggest an edit/i); - }); - - it('annotate composer placeholders read "Title" and "Details"', () => { - render( - {}} - annotateMode - onAnnotateRequest={() => {}} - onAnnotateCancel={() => {}} - onAnnotateSubmit={() => {}} - />, - ); - const summary = screen.getByLabelText('Annotation summary') as HTMLInputElement; - const body = screen.getByLabelText('Annotation body') as HTMLTextAreaElement; - expect(summary.placeholder).toBe('Title'); - expect(body.placeholder).toBe('Details'); - }); - - it('staged-patch discard button uses the X icon and is hidden until row hover/focus', () => { - const { container } = render( - {}} - stagedPatches={[{ id: 'p1', kind: 'annotate', summary: 'note' }]} - onDiscardPatch={() => {}} - />, - ); - const discard = container.querySelector('[aria-label^="Discard staged change"]') as HTMLButtonElement; - expect(discard).not.toBeNull(); - // Hidden by default, revealed on group hover/focus-within. - expect(discard.className).toMatch(/opacity-0/); - expect(discard.className).toMatch(/group-hover\/staged-row:opacity-100/); - }); -}); diff --git a/src/client/components/impact-chip.tsx b/src/client/components/impact-chip.tsx index 65008c87..0f3904d8 100644 --- a/src/client/components/impact-chip.tsx +++ b/src/client/components/impact-chip.tsx @@ -6,8 +6,8 @@ // - soft → cool blue (matches the Apply button) // - hard → warm amber (matches the deferred banner) // -// Shared by `side-chat-popover.tsx` (popover staged-patch row) and -// `patch-list-overlay.tsx` (canonical overlay expanded list) so both +// Shared by `patch-list-overlay.tsx` (canonical overlay expanded list) and +// `secondary-chat-staging-strip.tsx` (per-chat inline staging strip) so both // surfaces speak the same visual language. import type { EditImpactTier } from './patch-list-reducer.js'; diff --git a/src/client/components/patch-list-host.tsx b/src/client/components/patch-list-host.tsx index 19c92418..a0098f5f 100644 --- a/src/client/components/patch-list-host.tsx +++ b/src/client/components/patch-list-host.tsx @@ -1,6 +1,6 @@ -// Patch-list module's public surface (D132). Mirrors `SideChatHost`: -// `` + `useFoo()` hooks. Internal state is an event log -// (`patch-list-reducer.ts`); the React layer is glue. +// Patch-list module's public surface (D132). `` plus +// `useFoo()` hooks. Internal state is an event log (`patch-list-reducer.ts`); +// the React layer is glue. import { createContext, useCallback, useContext, useMemo, useReducer, useRef, type ReactNode } from 'react'; @@ -287,3 +287,52 @@ export function useStagedPatches(filter?: StagedPatchesFilter): readonly Patch[] return staged; }, [ctx, anchorKind, anchorItemId, filterKind]); } + +// ---- Per-chat selector seam (FE-716 C5c, Shape A) ---- + +/** + * Per-chat view of the patch list scoped to one secondary chat. Returns the + * filtered staged slice (only patches whose `producerChatId === chatId`) plus + * scoped actions: `apply()` automatically targets the chat's patch ids, + * `discard`/`editSummary` reject ids that don't belong to the chat (they + * wouldn't surface in `staged` anyway, but the guard keeps the public seam + * tight). Sharing one provider keeps the apply pipeline + undo handles in + * one place; the partition lives at this selector layer per the C5c + * "Shape A" decision in CARDS.md. + */ +export interface PatchListForChat { + staged: readonly Patch[]; + count: number; + isApplying: boolean; + canUndo: boolean; + stage: (input: StagePatchInput) => string; + discard: (id: string) => void; + editSummary: (id: string, summary: string) => void; + apply: () => Promise; + undo: () => Promise; +} + +export function usePatchListForChat(chatId: number): PatchListForChat | null { + const ctx = useContext(PatchListContext); + return useMemo(() => { + if (!ctx) return null; + const staged = ctx.state.staged.filter((patch) => patch.producerChatId === chatId); + const stagedIds = new Set(staged.map((patch) => patch.id)); + const lastBatchTouchesChat = ctx.state.lastBatchPatches.some((patch) => patch.producerChatId === chatId); + return { + staged, + count: staged.length, + isApplying: ctx.state.isApplying, + canUndo: ctx.state.canUndo && lastBatchTouchesChat, + stage: ctx.actions.stage, + discard: (id: string) => { + if (stagedIds.has(id)) ctx.actions.discard(id); + }, + editSummary: (id: string, summary: string) => { + if (stagedIds.has(id)) ctx.actions.editSummary(id, summary); + }, + apply: () => ctx.actions.apply(staged.map((patch) => patch.id)), + undo: () => ctx.actions.undo(), + }; + }, [ctx, chatId]); +} diff --git a/src/client/components/patch-list-reducer.test.ts b/src/client/components/patch-list-reducer.test.ts index b0bcfd25..4c13abaa 100644 --- a/src/client/components/patch-list-reducer.test.ts +++ b/src/client/components/patch-list-reducer.test.ts @@ -16,6 +16,7 @@ import { function makeAnnotatePatch(id: string, overrides: Partial = {}): AnnotatePatch { return { kind: 'annotate', + producerChatId: null, id, anchor: { kind: 'decision', itemId: 1 }, summary: `summary-${id}`, @@ -322,6 +323,7 @@ describe('full sequence round-trip', () => { // Test helper assertion: StagePatchInput is the right shape for callers const _stageInputAnnotate: StagePatchInput = { kind: 'annotate', + producerChatId: null, anchor: { kind: 'decision', itemId: 1 }, summary: 's', body: 'b', @@ -330,6 +332,7 @@ void _stageInputAnnotate; const _stageInputEdit: StagePatchInput = { kind: 'edit', + producerChatId: null, anchor: { kind: 'decision', itemId: 1 }, summary: 's', newContent: 'new text', @@ -338,6 +341,7 @@ void _stageInputEdit; const _stageInputEdge: StagePatchInput = { kind: 'edge', + producerChatId: null, anchor: { kind: 'decision', itemId: 1 }, summary: 's', targetAnchor: { kind: 'goal', itemId: 2 }, @@ -347,6 +351,7 @@ void _stageInputEdge; const _stageInputDrillDown: StagePatchInput = { kind: 'drill-down', + producerChatId: null, anchor: { kind: 'decision', itemId: 1 }, summary: 's', focusArea: 'performance', @@ -362,6 +367,7 @@ describe('typings — ApplyPatchFn return shape', () => { const patch: AnnotatePatch = { id: 'p1', kind: 'annotate', + producerChatId: null, anchor: { kind: 'decision', itemId: 1 }, summary: 's', body: 'b', @@ -378,6 +384,7 @@ describe('patch-list reducer — appliedMeta on BatchApplied', () => { const patch: AnnotatePatch = { id: 'p1', kind: 'annotate', + producerChatId: null, anchor: { kind: 'decision', itemId: 5 }, summary: 's', body: 'b', @@ -427,6 +434,7 @@ describe('patch-list reducer — appliedMeta on BatchApplied', () => { function makeEditPatch(id: string, overrides: Partial = {}): EditPatch { return { kind: 'edit', + producerChatId: null, id, anchor: { kind: 'decision', itemId: 1 }, summary: `edit-${id}`, @@ -439,6 +447,7 @@ function makeEditPatch(id: string, overrides: Partial = {}): EditPatc function makeEdgePatch(id: string, overrides: Partial = {}): EdgePatch { return { kind: 'edge', + producerChatId: null, id, anchor: { kind: 'decision', itemId: 1 }, summary: `edge-${id}`, @@ -452,6 +461,7 @@ function makeEdgePatch(id: string, overrides: Partial = {}): EdgePatc function makeDrillDownPatch(id: string, overrides: Partial = {}): DrillDownPatch { return { kind: 'drill-down', + producerChatId: null, id, anchor: { kind: 'decision', itemId: 1 }, summary: `drill-${id}`, diff --git a/src/client/components/patch-list-reducer.ts b/src/client/components/patch-list-reducer.ts index e4ee97b6..6420cc52 100644 --- a/src/client/components/patch-list-reducer.ts +++ b/src/client/components/patch-list-reducer.ts @@ -2,6 +2,16 @@ // Events are the internal primitive — append-only — shaped to match A71's // future server-side `appendPatch(spec, patch[])` so migration is a reducer // swap, not a public-API rewrite. Public surface is `patch-list-host.tsx`. +// +// Per-chat scoping (FE-716 C5c, Shape A): each patch carries +// `producerChatId: number | null`. A null value means "popover / global +// origin"; a numeric value scopes the patch to one secondary chat. The +// reducer itself stays oblivious to the scope — partitioning is enforced +// at the selector layer (`usePatchListForChat`). Apply batches honour the +// per-chat scope implicitly because each chat's apply only ever passes +// `patchIds` derived from its own staged slice; cross-chat undo is not +// supported in V1 (per-`apply()`-batch undo only — chat scope is implicit +// in the patch ids of the batch). import type { KnowledgeKind } from '@/shared/knowledge.js'; @@ -23,6 +33,14 @@ interface PatchBase { summary: string; selectionRange?: PatchSelectionRange; createdAt: number; + /** + * Origin scope of the patch. `null` = popover / global (legacy default, + * what `usePatchList()` sees). A numeric value names the secondary chat + * that produced the patch; only `usePatchListForChat(chatId)` surfaces it. + * Required-but-nullable so the type system surfaces every stage call site + * (Shape A from FE-716 C5c planning). + */ + producerChatId: number | null; // Snapshot of the anchor item's reference code (e.g. "C1", "D5") at stage // time. Optional — populated by callers that have it in hand (side-chat // pinnedItem, future direct-edit row) so consumers like PatchListOverlay @@ -48,10 +66,11 @@ export interface EditPatch extends PatchBase { // may stage without server pre-classification. impact?: EditImpactTier; // Snapshot of the anchor item's current content at stage time. Populated - // by callers that have it in hand (e.g. side-chat-host with pinnedItem) - // so consumers like PatchListOverlay can render a word-level - // without re-querying the entity store. Optional — when absent, consumers - // fall back to summary-only display (FE-665 follow-up). + // by callers that have it in hand (e.g. structured-list direct-edit, or + // inline secondary-chat patch staging) so consumers like PatchListOverlay + // can render a word-level without re-querying the entity + // store. Optional — when absent, consumers fall back to summary-only + // display (FE-665 follow-up). currentContent?: string; } diff --git a/src/client/components/pending-review-section.tsx b/src/client/components/pending-review-section.tsx index 7bb3627a..e6eb6e70 100644 --- a/src/client/components/pending-review-section.tsx +++ b/src/client/components/pending-review-section.tsx @@ -45,7 +45,7 @@ import type { ReconciliationNeedRecord } from '@/shared/reconciliation-need.js'; import { ClassificationChip } from './classification-chip.js'; import { DiffPopover } from './diff-popover.js'; -import { useSideChat } from './side-chat-host.js'; +import { useSecondaryChatTrigger } from './secondary-chat-trigger.js'; // Card 3 (V3.1 setup): per-row inline edit state. Keyed by need id so // expanding one row's edit form doesn't perturb other rows. Draft text is @@ -101,7 +101,7 @@ export function PendingReviewSection(): React.ReactElement | null { mode: 'source-diff' | 'agent-proposal'; } | null>(null); const diffAnchorRef = useRef(null); - const sideChat = useSideChat(); + const secondaryChatTrigger = useSecondaryChatTrigger(); useEffect(() => { if (diffPopoverNeedId === null) return; @@ -247,18 +247,17 @@ export function PendingReviewSection(): React.ReactElement | null { const handleOpenSideChat = (need: ReconciliationNeedRecord): void => { if ( - sideChat === null || - need.target_item_kind === null || - need.target_reference_code === null || - need.target_current_content === null + secondaryChatTrigger === null || + !secondaryChatTrigger.canCreate || + secondaryChatTrigger.isPending || + need.target_item_kind === null ) { return; } - sideChat.openFor({ + void secondaryChatTrigger.create({ kind: need.target_item_kind, id: need.target_item_id, - referenceCode: need.target_reference_code, - content: need.target_current_content, + reconciliationNeedId: need.id, }); }; @@ -448,10 +447,9 @@ export function PendingReviewSection(): React.ReactElement | null { const showOpenSideChatButton = need.agent_status === 'classified' && need.agent_classification === 'substantive' && - sideChat !== null && - need.target_item_kind !== null && - need.target_reference_code !== null && - need.target_current_content !== null; + secondaryChatTrigger !== null && + secondaryChatTrigger.canCreate && + need.target_item_kind !== null; const rowDisabled = isResolving || isSaving || isResetting || isApplying || bulkOperation !== null; const showSourceDiff = need.source_previous_content !== null && diff --git a/src/client/components/secondary-chat-collapsible.tsx b/src/client/components/secondary-chat-collapsible.tsx new file mode 100644 index 00000000..f6d7ddf6 --- /dev/null +++ b/src/client/components/secondary-chat-collapsible.tsx @@ -0,0 +1,274 @@ +import { useState, type ReactNode } from 'react'; +import type { z } from 'zod/v4'; + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/client/components/ui/collapsible'; +import { cn } from '@/client/lib/utils'; +import type { secondaryChatStateSchema } from '@/shared/api-types.js'; + +import type { SecondaryChatMode } from './secondary-chat-trigger.js'; + +type SecondaryChat = z.infer; +type SecondaryChatTurn = SecondaryChat['turns'][number]; +type SecondaryChatPinnedReconciliationNeed = NonNullable; + +const RECONCILIATION_KIND_LABEL: Record = { + supersedes: 'Supersedes', + needs_confirmation: 'Needs confirmation', +}; + +export interface SecondaryChatCollapsibleProps { + secondaryChat: SecondaryChat; + /** + * Optional handler for mode toggle. When omitted, the mode chip is rendered + * read-only (V3.1 popover tests render the collapsible without a mutation context). + */ + onSetMode?: (mode: SecondaryChatMode) => void; + isModeUpdating?: boolean; + /** + * Optional composer hook. When provided, a single-line composer is rendered + * below the persisted turns; submitting calls `onSubmitMessage` with the + * trimmed input. The host (`SecondaryChatHost`) wires this to the C5a route. + */ + onSubmitMessage?: (message: string) => void; + /** + * Optional in-flight assistant text to render after persisted turns while a + * stream is mid-flight. Disappears when the bundle invalidates and the + * persisted assistant turn replaces it. + */ + streamingAssistantText?: string; + isStreaming?: boolean; + /** + * Optional slot rendered inside the collapsible body, after persisted turns + * and any in-flight assistant text but before the composer. Used by + * `` to mount the per-chat staging strip + * (``) without coupling the presentational + * collapsible to the patch-list module. + */ + bodyExtras?: ReactNode; +} + +export function SecondaryChatCollapsible({ + secondaryChat, + onSetMode, + isModeUpdating, + onSubmitMessage, + streamingAssistantText, + isStreaming, + bodyExtras, +}: SecondaryChatCollapsibleProps) { + const kickoffContent = secondaryChat.kickoffTurn?.assistant_parts ?? ''; + const mode = secondaryChat.chat.mode ?? 'explore'; + + return ( + +
+ + Secondary chat + + ▾ + + + +
+ + {secondaryChat.pinnedReconciliationNeed && ( + + )} + {kickoffContent &&
{kickoffContent}
} + {secondaryChat.turns.map((turn) => ( + + ))} + {isStreaming && streamingAssistantText !== undefined && ( +
+ {streamingAssistantText} +
+ )} + {bodyExtras} + {onSubmitMessage && ( + + )} +
+
+ ); +} + +function SecondaryChatReconciliationPanel({ need }: { need: SecondaryChatPinnedReconciliationNeed }) { + return ( +
+
+ + {RECONCILIATION_KIND_LABEL[need.kind]} + + Elements being reconciled +
+ + +
+ ); +} + +function SecondaryChatReconciliationEndpoint({ + role, + refCode, + excerpt, + fallbackId, +}: { + role: 'source' | 'target'; + refCode: string | null; + excerpt: string | null; + fallbackId: number; +}) { + return ( +
+ {role} + {refCode ?? `#${fallbackId}`} + {excerpt !== null && excerpt.length > 0 && ( + <> + · + {excerpt} + + )} +
+ ); +} + +function SecondaryChatTurnRow({ turn }: { turn: SecondaryChatTurn }) { + if (turn.user_parts !== null && turn.user_parts !== undefined) { + return ( +
+ {turn.user_parts} +
+ ); + } + if (turn.assistant_parts !== null && turn.assistant_parts !== undefined) { + return ( +
+ {turn.assistant_parts} +
+ ); + } + return null; +} + +function SecondaryChatComposer({ + onSubmitMessage, + disabled, +}: { + onSubmitMessage: (message: string) => void; + disabled?: boolean; +}) { + const [draft, setDraft] = useState(''); + + return ( +
{ + event.preventDefault(); + const trimmed = draft.trim(); + if (trimmed.length === 0 || disabled) return; + onSubmitMessage(trimmed); + setDraft(''); + }} + className="flex items-center gap-2 pt-1" + > + setDraft(event.target.value)} + disabled={disabled} + placeholder="Ask a follow-up…" + className="flex-1 rounded border border-rule bg-background px-2 py-1 text-sm focus:ring-1 focus:ring-rule focus:outline-none" + /> + +
+ ); +} + +function SecondaryChatModeToggle({ + mode, + onSetMode, + disabled, +}: { + mode: SecondaryChatMode; + onSetMode?: (mode: SecondaryChatMode) => void; + disabled?: boolean; +}) { + const interactive = Boolean(onSetMode); + const handleClick = (next: SecondaryChatMode) => () => { + if (!onSetMode || disabled || mode === next) return; + onSetMode(next); + }; + + return ( + + + + + ); +} diff --git a/src/client/components/secondary-chat-host.tsx b/src/client/components/secondary-chat-host.tsx new file mode 100644 index 00000000..8de4434e --- /dev/null +++ b/src/client/components/secondary-chat-host.tsx @@ -0,0 +1,161 @@ +import { useCallback, useRef, useState } from 'react'; +import type { z } from 'zod/v4'; + +import { streamSecondaryChatMessage } from '@/client/lib/secondary-chat-stream.js'; +import { useInvalidateSpecificationQueryDomains } from '@/client/routes/specification/$id/-specification-data.js'; +import type { secondaryChatStateSchema } from '@/shared/api-types.js'; + +import { usePatchListForChat, type PatchListForChat } from './patch-list-host.js'; +import { SecondaryChatCollapsible } from './secondary-chat-collapsible.js'; +import { SecondaryChatStagingStrip } from './secondary-chat-staging-strip.js'; +import { useSetSecondaryChatModeMutation } from './secondary-chat-trigger.js'; + +type SecondaryChat = z.infer; + +interface SecondaryChatStreamState { + readonly isStreaming: boolean; + readonly assistantText: string; + readonly send: (message: string) => Promise; +} + +function summarizeEditContent(content: string): string { + const trimmed = content.trim(); + return trimmed.length > 60 ? `${trimmed.slice(0, 57)}…` : trimmed; +} + +/** + * Per-chat hook owning the streaming lifecycle for one secondary chat. + * Posts to `POST /secondary-chats/:chatId/messages`, accumulates assistant + * text deltas for live display, and invalidates the specification bundle on + * completion so the persisted user/assistant turns replace the in-flight + * `assistantText` on next render. + * + * Each instance owns its own in-flight ref so multiple secondary chats can + * stream in parallel without state cross-talk. `propose_*` SSE chunks are + * translated into chat-scoped staged patches via `usePatchListForChat`. + */ +function useSecondaryChatStream( + secondaryChat: SecondaryChat, + patchList: PatchListForChat | null, +): SecondaryChatStreamState { + const specificationId = secondaryChat.chat.specification_id; + const chatId = secondaryChat.chat.id; + const pinnedAnchor = secondaryChat.chat.pinned_item_id; + const { invalidateSpecificationBundle } = useInvalidateSpecificationQueryDomains(); + const [isStreaming, setIsStreaming] = useState(false); + const [assistantText, setAssistantText] = useState(''); + const inFlightRef = useRef(null); + const patchListRef = useRef(patchList); + patchListRef.current = patchList; + + const pinnedItemKind = secondaryChat.pinnedItemKind; + + const send = useCallback( + async (message: string): Promise => { + if (inFlightRef.current) return; // ignore overlapping submits — one stream per chat + const controller = new AbortController(); + inFlightRef.current = controller; + setIsStreaming(true); + setAssistantText(''); + + try { + await streamSecondaryChatMessage( + { specificationId, chatId, message, signal: controller.signal }, + (event) => { + if (event.type === 'text-delta') { + setAssistantText((prev) => prev + event.delta); + return; + } + if (event.type !== 'patch-proposal') return; + // Patch staging requires a chat-scoped patch list, a pinned + // item id (anchor), and the resolved item kind. Without any of + // them, the proposal can't be routed; drop it silently — the + // assistant text already conveys the model's intent. + const list = patchListRef.current; + if (!list || pinnedAnchor === null || pinnedItemKind === null) return; + const anchor = { kind: pinnedItemKind, itemId: pinnedAnchor }; + if (event.toolName === 'propose_edit') { + list.stage({ + kind: 'edit', + producerChatId: chatId, + anchor, + summary: summarizeEditContent(event.input.newContent), + newContent: event.input.newContent, + ...(event.input.newRationale ? { newRationale: event.input.newRationale } : {}), + ...(event.impact !== undefined ? { impact: event.impact } : {}), + }); + } else if (event.toolName === 'propose_edge') { + list.stage({ + kind: 'edge', + producerChatId: chatId, + anchor, + // Resolving targetReferenceCode → (kind, itemId) requires an + // entity lookup not threaded through the secondary-chat + // bundle for V1; mirror the anchor item as a placeholder so + // the staged row renders with a correct relation label. + // PR follow-up: surface a target-resolver hook. + targetAnchor: anchor, + relation: event.input.relation, + summary: `Edge: ${event.input.targetReferenceCode} (${event.input.relation})`, + }); + } else if (event.toolName === 'propose_drill_down') { + list.stage({ + kind: 'drill-down', + producerChatId: chatId, + anchor, + summary: `Drill-down: ${event.input.focusArea}`, + focusArea: event.input.focusArea, + }); + } + }, + ); + } catch { + // Swallow stream errors at this layer; the bundle invalidation below + // surfaces the persisted partial transcript on the next render. + } finally { + inFlightRef.current = null; + setIsStreaming(false); + setAssistantText(''); + await invalidateSpecificationBundle(); + } + }, + [chatId, invalidateSpecificationBundle, pinnedAnchor, pinnedItemKind, specificationId], + ); + + return { isStreaming, assistantText, send }; +} + +export interface SecondaryChatHostProps { + secondaryChat: SecondaryChat; +} + +/** + * Per-chat host component that owns ALL per-chat mutation/streaming hooks for + * one secondary chat and renders `` with wired + * props. Replaces the prior `SecondaryChatCollapsibleWithMode` wrapper — + * folding the mode mutation, the stream consumer, and the staging strip into + * a single seam keeps the artifact-renderer one component shallower. + */ +export function SecondaryChatHost({ secondaryChat }: SecondaryChatHostProps) { + const specificationId = secondaryChat.chat.specification_id; + const chatId = secondaryChat.chat.id; + const modeMutation = useSetSecondaryChatModeMutation(specificationId, chatId); + const patchList = usePatchListForChat(chatId); + const stream = useSecondaryChatStream(secondaryChat, patchList); + + return ( + { + void modeMutation.setMode(next); + }} + isModeUpdating={modeMutation.isPending} + onSubmitMessage={(message) => { + void stream.send(message); + }} + streamingAssistantText={stream.assistantText} + isStreaming={stream.isStreaming} + bodyExtras={} + /> + ); +} diff --git a/src/client/components/secondary-chat-staging-strip.tsx b/src/client/components/secondary-chat-staging-strip.tsx new file mode 100644 index 00000000..c96d7823 --- /dev/null +++ b/src/client/components/secondary-chat-staging-strip.tsx @@ -0,0 +1,120 @@ +import { Check, Undo2, X } from 'lucide-react'; + +import { cn } from '@/client/lib/utils'; + +import { ContentDiff } from './content-diff.js'; +import { ImpactChip } from './impact-chip.js'; +import { usePatchListForChat } from './patch-list-host.js'; + +/** + * Per-secondary-chat staged-patches strip. + * + * Subscribes to `usePatchListForChat(chatId)` (Shape A partition seam from + * FE-716 C5c) so it sees only patches whose `producerChatId === chatId`. + * Renders a compact list of staged patches with apply/undo/discard controls + * and an inline `` for `edit` patches whose before/after pair is + * available. Mounted inside ``'s collapsible body. + * + * Intentionally minimal — the popover's full visual treatment (accent + * tinting, hover affordances, "view diff" popover) is not harvested wholesale + * for V1; the inline surface needs only the apply/undo loop. Forks can lift + * `` / `` to a shared location if a third caller + * appears. + */ +export interface SecondaryChatStagingStripProps { + chatId: number; +} + +export function SecondaryChatStagingStrip({ chatId }: SecondaryChatStagingStripProps) { + const patchList = usePatchListForChat(chatId); + if (!patchList || patchList.staged.length === 0) { + return null; + } + + return ( +
+
+ + {patchList.staged.length} pending change{patchList.staged.length === 1 ? '' : 's'} + +
+
    + {patchList.staged.map((patch) => ( +
  • +
    + {patch.kind} + + {patch.summary} + + {patch.kind === 'edit' && patch.impact ? : null} + +
    + {patch.kind === 'edit' && + typeof patch.currentContent === 'string' && + typeof patch.newContent === 'string' && + patch.currentContent !== patch.newContent ? ( +
    + +
    + ) : null} +
  • + ))} +
+
+ {patchList.isApplying ? ( + + Saving change… + + ) : null} + {patchList.canUndo ? ( + + ) : null} + +
+
+ ); +} diff --git a/src/client/components/secondary-chat-trigger.tsx b/src/client/components/secondary-chat-trigger.tsx new file mode 100644 index 00000000..3d88410b --- /dev/null +++ b/src/client/components/secondary-chat-trigger.tsx @@ -0,0 +1,239 @@ +import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'; + +import { + ClientMutationError, + postJsonMutation, + useClientMutation, +} from '@/client/mutations/client-mutation.js'; +import { + useInvalidateSpecificationQueryDomains, + useSpecificationBundleData, +} from '@/client/routes/specification/$id/-specification-data.js'; +import type { MutationErrorResponse } from '@/shared/api-types.js'; +import type { KnowledgeKind } from '@/shared/knowledge.js'; +import { + getCurrentOpenPhase, + getPhaseRoutePath, + groundingWorkflowPhase, +} from '@/shared/phase-descriptors.js'; + +export type SecondaryChatMode = 'explore' | 'edit'; + +async function patchJsonMutation( + url: string, + body: TRequest, + fallbackMessage: string, +): Promise { + let response: Response; + try { + response = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch { + throw new ClientMutationError(fallbackMessage); + } + if (!response.ok) { + let message = fallbackMessage; + try { + const payload = (await response.json()) as MutationErrorResponse; + if (typeof payload.error === 'string' && payload.error.trim().length > 0) { + message = payload.error; + } + } catch { + // ignore + } + throw new ClientMutationError(message, response.status); + } + try { + return (await response.json()) as TResponse; + } catch { + throw new ClientMutationError(fallbackMessage, response.status); + } +} + +export interface CreateSecondaryChatRequest { + parentChatId: number; + invokedInTurnId: number; + itemKind: KnowledgeKind; + itemId: number; + spanHint?: string; + /** + * FE-716 C9: when the chat is opened from a substantive `reconciliation_need` + * row, pass the need id so the server persists `pinned_reconciliation_need_id` + * and the inline collapsible can render the "elements being reconciled" panel. + */ + reconciliationNeedId?: number; +} + +export interface CreateSecondaryChatResponse { + chatId: number; + kickoffTurnId: number; +} + +export function useCreateSecondaryChatMutation(specificationId: number) { + const { invalidateSpecificationBundle } = useInvalidateSpecificationQueryDomains(); + const mutation = useClientMutation((request: CreateSecondaryChatRequest) => + postJsonMutation( + `/api/specifications/${specificationId}/secondary-chats`, + request, + 'Failed to open secondary chat', + ), + ); + + const create = useCallback( + async (request: CreateSecondaryChatRequest): Promise => { + try { + const response = await mutation.run(request); + await invalidateSpecificationBundle(); + return response; + } catch { + return null; + } + }, + [invalidateSpecificationBundle, mutation], + ); + + return { + create, + isPending: mutation.isPending, + errorMessage: mutation.errorMessage, + clearError: mutation.clearError, + }; +} + +export interface SecondaryChatTriggerItem { + kind: KnowledgeKind; + id: number; + /** + * Optional highlighted span from the source row — when present, the server + * persists it as `pinned_span_hint` on the new chat so the kickoff turn can + * focus the conversation on that excerpt. + */ + spanHint?: string; + /** + * Optional `reconciliation_need.id` when the trigger fires from a substantive + * reconciliation row (FE-716 C9). Persisted as `pinned_reconciliation_need_id` + * so the inline collapsible renders the "elements being reconciled" panel. + */ + reconciliationNeedId?: number; +} + +export interface InlineChatRoute { + /** TanStack-Router `to` path for the route that renders inline chats. */ + readonly to: string; + /** Route params required for `to` (currently just the specification id). */ + readonly params: { readonly id: string }; +} + +export interface SecondaryChatTriggerValue { + readonly canCreate: boolean; + readonly isPending: boolean; + readonly create: (item: SecondaryChatTriggerItem) => Promise; + /** + * Route descriptor that surfaces the newly-created inline chat. Callers that + * render outside the transcript view (e.g. the graph / structured list) should + * navigate here after a successful `create`, since only the transcript view + * renders `SecondaryChatCollapsible`. Always set, even when `canCreate` is + * false — that way callers can compute it once during render. + */ + readonly inlineChatRoute: InlineChatRoute; +} + +const SecondaryChatTriggerContext = createContext(null); + +export function useSecondaryChatTrigger(): SecondaryChatTriggerValue | null { + return useContext(SecondaryChatTriggerContext); +} + +/** + * Provides the inline-secondary-chat creation callback to descendants. Reads the + * specification bundle for `primary_chat_id` (parent chat) and `active_turn_id` + * (anchor turn). When either is missing, `canCreate` is false and `create` rejects. + */ +export function SecondaryChatTriggerProvider({ children }: { children: ReactNode }) { + const specificationState = useSpecificationBundleData(); + const specificationId = specificationState.specification.id; + const parentChatId = specificationState.specification.primary_chat_id ?? null; + const activeTurnId = specificationState.specification.active_turn_id; + const mutation = useCreateSecondaryChatMutation(specificationId); + + const canCreate = parentChatId !== null && activeTurnId !== null; + + const create = useCallback( + async (item: SecondaryChatTriggerItem) => { + if (parentChatId === null || activeTurnId === null) { + return null; + } + return mutation.create({ + parentChatId, + invokedInTurnId: activeTurnId, + itemKind: item.kind, + itemId: item.id, + ...(item.spanHint ? { spanHint: item.spanHint } : {}), + ...(item.reconciliationNeedId !== undefined + ? { reconciliationNeedId: item.reconciliationNeedId } + : {}), + }); + }, + [activeTurnId, mutation, parentChatId], + ); + + const inlineChatRoute = useMemo(() => { + const activePhase = getCurrentOpenPhase(specificationState.workflow.phases) ?? groundingWorkflowPhase; + return { + to: getPhaseRoutePath(activePhase), + params: { id: String(specificationId) }, + }; + }, [specificationId, specificationState.workflow.phases]); + + const value = useMemo( + () => ({ canCreate, isPending: mutation.isPending, create, inlineChatRoute }), + [canCreate, create, inlineChatRoute, mutation.isPending], + ); + + return ( + {children} + ); +} + +export interface SetSecondaryChatModeRequest { + mode: SecondaryChatMode; +} + +export interface SetSecondaryChatModeResponse { + chatId: number; + mode: SecondaryChatMode; +} + +export function useSetSecondaryChatModeMutation(specificationId: number, chatId: number) { + const { invalidateSpecificationBundle } = useInvalidateSpecificationQueryDomains(); + const mutation = useClientMutation((request: SetSecondaryChatModeRequest) => + patchJsonMutation( + `/api/specifications/${specificationId}/secondary-chats/${chatId}/mode`, + request, + 'Failed to update secondary chat mode', + ), + ); + + const setMode = useCallback( + async (mode: SecondaryChatMode): Promise => { + try { + const response = await mutation.run({ mode }); + await invalidateSpecificationBundle(); + return response; + } catch { + return null; + } + }, + [invalidateSpecificationBundle, mutation], + ); + + return { + setMode, + isPending: mutation.isPending, + errorMessage: mutation.errorMessage, + clearError: mutation.clearError, + }; +} diff --git a/src/client/components/side-chat-host.tsx b/src/client/components/side-chat-host.tsx deleted file mode 100644 index ada09350..00000000 --- a/src/client/components/side-chat-host.tsx +++ /dev/null @@ -1,926 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react'; - -import { - listAnnotationsForSpecificationRequest, - type CreatedAnnotation, -} from '@/client/lib/annotation-api.js'; -import { - streamSideChatResponse, - type SideChatMode, - type SideChatPriorTurn, -} from '@/client/lib/side-chat-stream.js'; -import { queryClient } from '@/client/query-client.js'; -import { specificationQueryKeys } from '@/client/routes/specification/$id/-specification-data.js'; -import type { EntitiesData } from '@/shared/api-types.js'; -import type { KnowledgeKind } from '@/shared/knowledge.js'; - -import { - useLastBatchAppliedMeta, - usePatchList, - usePatchListState, - useStagedPatches, -} from './patch-list-host.js'; -import { PatchListOverlayBridgeProvider } from './patch-list-overlay-bridge.js'; -import { PatchListUndoProvider } from './patch-list-undo-context.js'; -import { - SideChatPopover, - type SideChatExistingAnnotation, - type SideChatMessage, - type SideChatPinnedItem, - type SideChatStagedPatchSummary, - type SideChatThreadItem, -} from './side-chat-popover.js'; - -export interface SideChatPinnableItem { - kind: KnowledgeKind; - id: number; - referenceCode: string; - content: string; -} - -interface SideChatContextValue { - openFor: (item: SideChatPinnableItem) => void; - openWithSpanHint: (item: SideChatPinnableItem, hint: string) => void; - activeCardIds: readonly number[]; - dismissCard: (annotationId: number) => void; - clearSpanHint: () => void; - promoteAnnotation: (annotationId: number) => void; - setMode: (mode: SideChatMode) => void; -} - -const SideChatContext = createContext(null); - -export function useSideChat(): SideChatContextValue | null { - return useContext(SideChatContext); -} - -// Cap on how many ActiveCards we send as `activeAnnotations` in the stream payload, -// and how many of them we mark `inContext` in the rendered thread. Single source of truth -// referenced from both the request builder and the threadItems derivation. -const MAX_ACTIVE_ANNOTATIONS = 8; - -interface ActiveSideChat { - sessionId: number; - pinnedItem: SideChatPinnedItem; - itemKind: KnowledgeKind; - itemId: number; - messages: SideChatMessage[]; - // Parallel array: messageTimestamps[i] is the wall-clock time when messages[i] was first - // appended (or, for the streamed assistant pending message, when it started streaming). - // Preserved across replacePendingText / finalizePending so the timestamp is stable across - // streaming. Used by threadItems to chronologically interleave with ActiveCard timestamps, - // which also use Date.now(). - messageTimestamps: number[]; - annotateMode: boolean; - // V2 chat-driven mode: 'explore' (default) keeps free-form chat; 'edit' tells the - // server to register the propose_edit tool so the LLM can stage edit patches. - mode: SideChatMode; -} - -interface LoadedAnnotations { - itemKind: KnowledgeKind; - itemId: number; - batchId: string | null; - items: readonly CreatedAnnotation[]; -} - -// Cap on how many characters of an edit's newContent we put into the -// patch-list summary string. Tunes the at-a-glance label visible in the -// top-bar `N Edits` overlay before the user clicks through. -const EDIT_SUMMARY_PREVIEW_LIMIT = 60; - -function summarizeEditContent(newContent: string): string { - const trimmed = newContent.trim(); - if (trimmed.length <= EDIT_SUMMARY_PREVIEW_LIMIT) { - return `Edit: ${trimmed}`; - } - return `Edit: ${trimmed.slice(0, EDIT_SUMMARY_PREVIEW_LIMIT - 1)}…`; -} - -interface ResolvedEdgeTarget { - kind: KnowledgeKind; - itemId: number; - referenceCode: string; -} - -// Resolve a referenceCode (e.g. "G3", "D7") to the corresponding -// (kind, itemId) by reading the project-wide entities cache. Returns null if -// no entity matches — propose_edge events with unresolvable references are -// silently dropped client-side; the LLM occasionally hallucinates codes. -function resolveEdgeTarget(specificationId: number, referenceCode: string): ResolvedEdgeTarget | null { - const data = queryClient.getQueryData( - specificationQueryKeys.entitiesProjectWide(String(specificationId)), - ) as EntitiesData | undefined; - if (!data) { - return null; - } - const groups: ReadonlyArray< - readonly [KnowledgeKind, ReadonlyArray<{ id: number; referenceCode?: string | null }>] - > = [ - ['goal', data.goals], - ['term', data.terms], - ['context', data.contexts], - ['constraint', data.constraints], - ['decision', data.decisions], - ['assumption', data.assumptions], - ['requirement', data.requirements], - ['criterion', data.criteria], - ]; - for (const [kind, items] of groups) { - for (const item of items) { - if (item.referenceCode === referenceCode) { - return { kind, itemId: item.id, referenceCode }; - } - } - } - return null; -} - -function replacePendingText(messages: readonly SideChatMessage[], text: string): SideChatMessage[] { - return messages.map((message) => (message.pending ? { ...message, text } : message)); -} - -function finalizePending(messages: readonly SideChatMessage[]): SideChatMessage[] { - return messages.flatMap((message) => { - if (!message.pending) { - return [message]; - } - return message.text ? [{ role: message.role, text: message.text }] : []; - }); -} - -// Mirrors finalizePending: when finalizePending drops an empty pending message, the -// corresponding entry in messageTimestamps must drop too so the parallel arrays stay aligned. -function finalizeTimestamps(messages: readonly SideChatMessage[], timestamps: readonly number[]): number[] { - const next: number[] = []; - messages.forEach((message, index) => { - const ts = timestamps[index] ?? Date.now(); - if (!message.pending) { - next.push(ts); - return; - } - if (message.text) { - next.push(ts); - } - }); - return next; -} - -function appliedPreviousContent(applied: unknown): string | null { - if (!applied || typeof applied !== 'object' || !('previousContent' in applied)) { - return null; - } - const previousContent = (applied as { previousContent?: unknown }).previousContent; - return typeof previousContent === 'string' ? previousContent : null; -} - -const SIDE_CHAT_ERROR_MESSAGE = 'Something went wrong — try again.'; - -function buildHistory(messages: readonly SideChatMessage[]): SideChatPriorTurn[] { - const history: SideChatPriorTurn[] = []; - for (const message of messages) { - if (message.pending || message.error || message.text.length === 0) { - if (message.role === 'assistant' && history.at(-1)?.role === 'user') { - history.pop(); - } - continue; - } - history.push({ role: message.role, text: message.text }); - } - if (history.at(-1)?.role === 'user') { - history.pop(); - } - return history; -} - -const SIDE_CHAT_LAYOUT_STORAGE_KEY = 'brunch.side-chat.layout'; -const SIDE_CHAT_MODE_STORAGE_KEY = 'brunch.side-chat.mode'; - -function readStoredLayout(): 'docked' | 'floating' { - if (typeof window === 'undefined') return 'docked'; - try { - return window.localStorage.getItem(SIDE_CHAT_LAYOUT_STORAGE_KEY) === 'floating' ? 'floating' : 'docked'; - } catch { - return 'docked'; - } -} - -function writeStoredLayout(layout: 'docked' | 'floating'): void { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(SIDE_CHAT_LAYOUT_STORAGE_KEY, layout); - } catch { - // Storage may be unavailable (privacy mode, sandboxed iframe, quota); ignore. - } -} - -// Card 4 follow-up: Edit-mode toggle persists across sessions and pinned items -// so the user's last preference survives reload. A new pinned item adopts the -// stored mode rather than always falling back to 'explore'. -function readStoredMode(): SideChatMode { - if (typeof window === 'undefined') return 'explore'; - try { - return window.localStorage.getItem(SIDE_CHAT_MODE_STORAGE_KEY) === 'edit' ? 'edit' : 'explore'; - } catch { - return 'explore'; - } -} - -function writeStoredMode(mode: SideChatMode): void { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(SIDE_CHAT_MODE_STORAGE_KEY, mode); - } catch { - // Storage may be unavailable; ignore. - } -} - -interface ActiveCard { - id: number; - itemKind: KnowledgeKind; - referenceCode: string; - summary: string; - body: string; - timestamp: number; -} - -function failPending(messages: readonly SideChatMessage[]): SideChatMessage[] { - let replaced = false; - const next = messages.map((message) => { - if (message.pending) { - replaced = true; - return { role: message.role, text: SIDE_CHAT_ERROR_MESSAGE, error: true } as SideChatMessage; - } - return message; - }); - if (!replaced) { - next.push({ role: 'assistant', text: SIDE_CHAT_ERROR_MESSAGE, error: true }); - } - return next; -} - -export function SideChatHost({ - specificationId, - children, -}: { - specificationId: number; - children: ReactNode; -}) { - const [activeSideChat, setActiveSideChat] = useState(null); - const [pendingSpanHint, setPendingSpanHint] = useState(null); - const [activeCards, setActiveCards] = useState([]); - const [layout, setLayout] = useState<'docked' | 'floating'>(readStoredLayout); - useEffect(() => { - writeStoredLayout(layout); - }, [layout]); - const activeRef = useRef(null); - const sessionCounterRef = useRef(0); - const streamControllerRef = useRef(null); - - useEffect(() => { - activeRef.current = activeSideChat; - }, [activeSideChat]); - - const abortActiveStream = useCallback(() => { - streamControllerRef.current?.abort(); - streamControllerRef.current = null; - }, []); - - useEffect(() => abortActiveStream, [abortActiveStream]); - - const openFor = useCallback( - (item: SideChatPinnableItem) => { - const current = activeRef.current; - if (current && current.itemKind === item.kind && current.itemId === item.id) { - const nextActiveSideChat = { - ...current, - pinnedItem: { referenceCode: item.referenceCode, content: item.content, kind: item.kind }, - }; - activeRef.current = nextActiveSideChat; - setActiveSideChat((active) => - active && active.itemKind === item.kind && active.itemId === item.id ? nextActiveSideChat : active, - ); - return; - } - - abortActiveStream(); - sessionCounterRef.current += 1; - // Single-pin scope: switching to a different (kind, id) clears cards/hint so stale - // state doesn't leak across items. Reopening the same item focuses the existing session. - setActiveCards([]); - setPendingSpanHint(null); - const nextActiveSideChat: ActiveSideChat = { - sessionId: sessionCounterRef.current, - pinnedItem: { referenceCode: item.referenceCode, content: item.content, kind: item.kind }, - itemKind: item.kind, - itemId: item.id, - messages: [], - messageTimestamps: [], - annotateMode: false, - // Card 4 follow-up: adopt the persisted mode so reopening the side-chat - // (or pinning a new item) inherits the user's last toggle state. - mode: readStoredMode(), - }; - activeRef.current = nextActiveSideChat; - setActiveSideChat(nextActiveSideChat); - }, - [abortActiveStream], - ); - - const openWithSpanHint = useCallback( - (item: SideChatPinnableItem, hint: string) => { - openFor(item); - setPendingSpanHint(hint); - }, - [openFor], - ); - - const clearSpanHint = useCallback(() => { - setPendingSpanHint(null); - }, []); - - const dismiss = useCallback(() => { - abortActiveStream(); - activeRef.current = null; - setActiveSideChat(null); - // Single-pin scope: closing the side-chat resets cards and any unsent span hint so - // they don't leak into the next item the user opens. - setActiveCards([]); - setPendingSpanHint(null); - }, [abortActiveStream]); - - const requestAnnotate = useCallback(() => { - setActiveSideChat((current) => (current ? { ...current, annotateMode: true } : current)); - }, []); - - const cancelAnnotate = useCallback(() => { - setActiveSideChat((current) => (current ? { ...current, annotateMode: false } : current)); - }, []); - - const setMode = useCallback((mode: SideChatMode) => { - // Persist before mutating in-memory state so a later reload (or a fresh - // pin via openFor) sees the latest preference even if the active session - // is dismissed before the next render commits. - writeStoredMode(mode); - const current = activeRef.current; - if (!current) { - return; - } - const nextActiveSideChat = { ...current, mode }; - activeRef.current = nextActiveSideChat; - setActiveSideChat((active) => - active && active.sessionId === current.sessionId ? nextActiveSideChat : active, - ); - }, []); - - // Ref to patchList so submitMessage's onChunk handler can stage patch-proposal - // events without taking patchList as a useCallback dep (which would re-create - // submitMessage and remount the popover composer on patch-list changes). - const patchListRef = useRef>(null); - - const pushActiveCard = useCallback((card: Omit) => { - setActiveCards((prev) => - prev.some((existing) => existing.id === card.id) ? prev : [...prev, { ...card, timestamp: Date.now() }], - ); - }, []); - const dismissCard = useCallback((annotationId: number) => { - setActiveCards((prev) => prev.filter((card) => card.id !== annotationId)); - }, []); - const activeCardIds: readonly number[] = useMemo(() => activeCards.map((card) => card.id), [activeCards]); - - const submitMessage = useCallback( - (message: string) => { - const session = activeRef.current; - if (!session) { - return; - } - const { sessionId } = session; - - abortActiveStream(); - const controller = new AbortController(); - streamControllerRef.current = controller; - const history = buildHistory(session.messages); - const hintForThisRequest = pendingSpanHint; - if (hintForThisRequest) { - setPendingSpanHint(null); - } - - setActiveSideChat((current) => { - if (!current || current.sessionId !== sessionId) { - return current; - } - // Record one wall-clock timestamp per appended message. The pending assistant - // message keeps its initial timestamp through replacePendingText so it doesn't - // bounce around in the chronological sort as deltas arrive. - const now = Date.now(); - return { - ...current, - messages: [ - ...current.messages, - { role: 'user', text: message }, - { role: 'assistant', text: '', pending: true }, - ], - messageTimestamps: [...current.messageTimestamps, now, now], - }; - }); - - const activeAnnotations = activeCards.slice(-MAX_ACTIVE_ANNOTATIONS).map((card) => ({ - referenceCode: card.referenceCode, - snapshot: card.summary, - body: card.body.length > 0 ? card.body : null, - })); - - void (async () => { - let buffered = ''; - let failed = false; - try { - await streamSideChatResponse( - { - specificationId, - itemKind: session.itemKind, - itemId: session.itemId, - message, - history, - signal: controller.signal, - ...(activeAnnotations.length > 0 ? { activeAnnotations } : {}), - ...(hintForThisRequest ? { spanHint: hintForThisRequest } : {}), - ...(session.mode !== 'explore' ? { mode: session.mode } : {}), - }, - (event) => { - if (controller.signal.aborted) { - return; - } - if (event.type === 'text-delta') { - buffered += event.delta; - setActiveSideChat((current) => - current && current.sessionId === sessionId - ? { ...current, messages: replacePendingText(current.messages, buffered) } - : current, - ); - } else if (event.type === 'patch-proposal' && event.toolName === 'propose_edit') { - const patchList = patchListRef.current; - if (!patchList) { - return; - } - patchList.stage({ - kind: 'edit', - anchor: { kind: session.itemKind, itemId: session.itemId }, - anchorReferenceCode: session.pinnedItem.referenceCode, - summary: summarizeEditContent(event.input.newContent), - // Capture the live current content at stage time so the - // canonical PatchListOverlay can render a word-level - // without re-querying the entity store. - // session.pinnedItem.content tracks live saved content via - // the apply-time refresh effect (FE-665 follow-up). - currentContent: session.pinnedItem.content, - newContent: event.input.newContent, - ...(event.input.newRationale ? { newRationale: event.input.newRationale } : {}), - ...(event.impact !== undefined ? { impact: event.impact } : {}), - }); - } else if (event.type === 'patch-proposal' && event.toolName === 'propose_edge') { - const patchList = patchListRef.current; - if (!patchList) { - return; - } - const target = resolveEdgeTarget(specificationId, event.input.targetReferenceCode); - if (!target) { - return; - } - patchList.stage({ - kind: 'edge', - anchor: { kind: session.itemKind, itemId: session.itemId }, - anchorReferenceCode: session.pinnedItem.referenceCode, - targetAnchor: { kind: target.kind, itemId: target.itemId }, - relation: event.input.relation, - summary: `Edge: ${session.pinnedItem.referenceCode} ${event.input.relation.replaceAll('_', ' ')} ${target.referenceCode}`, - }); - } else if (event.type === 'patch-proposal' && event.toolName === 'propose_drill_down') { - const patchList = patchListRef.current; - if (!patchList) { - return; - } - patchList.stage({ - kind: 'drill-down', - anchor: { kind: session.itemKind, itemId: session.itemId }, - anchorReferenceCode: session.pinnedItem.referenceCode, - summary: `Drill-down: ${event.input.focusArea}`, - focusArea: event.input.focusArea, - }); - } - }, - ); - } catch { - failed = !controller.signal.aborted; - } - if (controller.signal.aborted) { - return; - } - if (streamControllerRef.current === controller) { - streamControllerRef.current = null; - } - setActiveSideChat((current) => { - if (!current || current.sessionId !== sessionId) { - return current; - } - // failPending preserves message count (in-place replace + maybe append, but the - // original pending always exists here), so timestamps stay aligned. finalizePending - // may drop an empty pending message — finalizeTimestamps mirrors that drop. - const nextMessages = failed ? failPending(current.messages) : finalizePending(current.messages); - const nextTimestamps = failed - ? // failPending may push a new error message when no pending was found; pad with now. - nextMessages.length > current.messageTimestamps.length - ? [...current.messageTimestamps, Date.now()] - : current.messageTimestamps - : finalizeTimestamps(current.messages, current.messageTimestamps); - return { - ...current, - messages: nextMessages, - messageTimestamps: nextTimestamps, - }; - }); - })(); - }, - [specificationId, abortActiveStream, pendingSpanHint, activeCards], - ); - const patchList = usePatchList(); - patchListRef.current = patchList; - const patchListState = usePatchListState(); - const stagedForActive = useStagedPatches( - activeSideChat ? { anchor: { kind: activeSideChat.itemKind, itemId: activeSideChat.itemId } } : undefined, - ); - - const submitAnnotate = useCallback( - (summary: string, body: string) => { - if (!activeSideChat || !patchList) { - return; - } - patchList.stage({ - kind: 'annotate', - anchor: { kind: activeSideChat.itemKind, itemId: activeSideChat.itemId }, - anchorReferenceCode: activeSideChat.pinnedItem.referenceCode, - summary, - body, - }); - setActiveSideChat((current) => (current ? { ...current, annotateMode: false } : current)); - }, - [activeSideChat, patchList], - ); - - const stagedSummaries: readonly SideChatStagedPatchSummary[] = stagedForActive.map((patch) => ({ - id: patch.id, - kind: patch.kind, - summary: patch.summary, - ...(patch.kind === 'edit' && patch.impact !== undefined ? { impact: patch.impact } : {}), - // FE-665: when an edit patch targets the currently-pinned item, surface - // the before/after pair so the staged-patch row can render a word-level - // in its expander. pinnedItem.content tracks the live - // saved content via the apply-time refresh effect below, so this stays - // an honest "current vs proposed" view. - ...(patch.kind === 'edit' && - activeSideChat && - patch.anchor.kind === activeSideChat.itemKind && - patch.anchor.itemId === activeSideChat.itemId - ? { currentContent: activeSideChat.pinnedItem.content, newContent: patch.newContent } - : {}), - })); - const stagedForActiveIds = useMemo(() => stagedForActive.map((patch) => patch.id), [stagedForActive]); - const canUndoForActive = - patchListState.canUndo && - activeSideChat !== null && - patchListState.lastBatchPatches.some( - (patch) => - patch.anchor.kind === activeSideChat.itemKind && patch.anchor.itemId === activeSideChat.itemId, - ); - const lastBatchAppliedMeta = useLastBatchAppliedMeta(); - - const triggeredAutoApplyIdsRef = useRef>(new Set()); - useEffect(() => { - if (!patchList || patchListState.isApplying) return; - const triggered = triggeredAutoApplyIdsRef.current; - const stagedIds = new Set(patchListState.staged.map((patch) => patch.id)); - for (const id of triggered) { - if (!stagedIds.has(id)) triggered.delete(id); - } - const allAutoApplyable = stagedForActive.every((patch) => patch.kind === 'annotate'); - if (stagedForActive.length === 0 || !allAutoApplyable) return; - const hasUntriggered = stagedForActive.some((patch) => !triggered.has(patch.id)); - if (!hasUntriggered) return; - for (const patch of stagedForActive) { - triggered.add(patch.id); - } - void patchList.apply(stagedForActiveIds); - }, [patchList, patchListState.staged, patchListState.isApplying, stagedForActive, stagedForActiveIds]); - - const applyStagedForActive = useCallback(() => { - if (!patchList || stagedForActiveIds.length === 0) { - return; - } - void patchList.apply(stagedForActiveIds); - }, [patchList, stagedForActiveIds]); - - const patchListOverlayBridge = useMemo( - () => ({ - applyScoped: applyStagedForActive, - scopedPatchIds: stagedForActiveIds, - }), - [applyStagedForActive, stagedForActiveIds], - ); - - const undoForActive = useCallback(() => { - if (!patchList) { - return; - } - if (!activeSideChat) { - void patchList.undo(); - return; - } - const activeItemKind = activeSideChat.itemKind; - const activeItemId = activeSideChat.itemId; - const appliedByPatchId = new Map(lastBatchAppliedMeta.map((meta) => [meta.patchId, meta.applied])); - const revertedContent = patchListState.lastBatchPatches - .filter( - (patch) => - patch.kind === 'edit' && - patch.anchor.kind === activeItemKind && - patch.anchor.itemId === activeItemId, - ) - .map((patch) => appliedPreviousContent(appliedByPatchId.get(patch.id))) - .find((content): content is string => content !== null); - - void (async () => { - const undone = await patchList.undo(); - if (!undone || revertedContent === undefined) { - return; - } - setActiveSideChat((current) => - current && current.itemKind === activeItemKind && current.itemId === activeItemId - ? { ...current, pinnedItem: { ...current.pinnedItem, content: revertedContent } } - : current, - ); - if ( - activeRef.current && - activeRef.current.itemKind === activeItemKind && - activeRef.current.itemId === activeItemId - ) { - activeRef.current = { - ...activeRef.current, - pinnedItem: { ...activeRef.current.pinnedItem, content: revertedContent }, - }; - } - })(); - }, [patchList, activeSideChat, lastBatchAppliedMeta, patchListState.lastBatchPatches]); - - const lastSeenBatchIdRef = useRef(null); - useEffect(() => { - if (patchListState.lastBatchId === lastSeenBatchIdRef.current) return; - lastSeenBatchIdRef.current = patchListState.lastBatchId; - if (!activeSideChat) return; - const patchesById = new Map(patchListState.lastBatchPatches.map((patch) => [patch.id, patch])); - for (const meta of lastBatchAppliedMeta) { - if (meta.applied && typeof meta.applied === 'object' && 'id' in meta.applied) { - const sourcePatch = patchesById.get(meta.patchId); - if ( - !sourcePatch || - sourcePatch.anchor.kind !== activeSideChat.itemKind || - sourcePatch.anchor.itemId !== activeSideChat.itemId - ) { - continue; - } - const applied = meta.applied as { id: unknown; summary?: unknown; body?: unknown }; - const summary = typeof applied.summary === 'string' ? applied.summary.trim() : ''; - if (typeof applied.id === 'number' && summary.length > 0) { - pushActiveCard({ - id: applied.id, - itemKind: activeSideChat.itemKind, - referenceCode: activeSideChat.pinnedItem.referenceCode, - summary, - body: typeof applied.body === 'string' ? applied.body : '', - }); - } - } - } - // V2 chat-driven edits: when an edit patch applied to the active pinned - // item, refresh the popover's pinned-item snapshot so the chat header - // reflects the new content. Without this, the structured-list / graph - // view re-fetches and updates (per makeEditApplier's cache invalidation), - // but the side-chat popover keeps showing the pre-edit content because - // pinnedItem was captured at openFor() time. - const appliedByPatchIdForRefresh = new Map( - lastBatchAppliedMeta.map((meta) => [meta.patchId, meta.applied]), - ); - for (const patch of patchListState.lastBatchPatches) { - if ( - patch.kind === 'edit' && - patch.anchor.kind === activeSideChat.itemKind && - patch.anchor.itemId === activeSideChat.itemId - ) { - const applied = appliedByPatchIdForRefresh.get(patch.id); - const isDeferred = - !!applied && typeof applied === 'object' && (applied as { deferred?: unknown }).deferred === true; - if (isDeferred) continue; - const nextContent = patch.newContent; - setActiveSideChat((current) => - current && current.itemKind === patch.anchor.kind && current.itemId === patch.anchor.itemId - ? { ...current, pinnedItem: { ...current.pinnedItem, content: nextContent } } - : current, - ); - if ( - activeRef.current && - activeRef.current.itemKind === patch.anchor.kind && - activeRef.current.itemId === patch.anchor.itemId - ) { - activeRef.current = { - ...activeRef.current, - pinnedItem: { ...activeRef.current.pinnedItem, content: nextContent }, - }; - } - } - } - }, [ - activeSideChat, - patchListState.lastBatchId, - patchListState.lastBatchPatches, - lastBatchAppliedMeta, - pushActiveCard, - ]); - - const activeItemId = activeSideChat?.itemId; - const activeItemKind = activeSideChat?.itemKind; - const [annotations, setAnnotations] = useState(null); - const annotationsRef = useRef(null); - useEffect(() => { - annotationsRef.current = annotations; - }, [annotations]); - useEffect(() => { - if (activeItemId === undefined || activeItemKind === undefined) { - setAnnotations(null); - return; - } - let cancelled = false; - const batchId = patchListState.lastBatchId; - annotationsRef.current = null; - setAnnotations(null); - void listAnnotationsForSpecificationRequest(specificationId) - .then((list) => { - if (!cancelled) { - const loaded = { itemKind: activeItemKind, itemId: activeItemId, batchId, items: list }; - annotationsRef.current = loaded; - setAnnotations(loaded); - } - }) - .catch(() => { - if (!cancelled) { - annotationsRef.current = null; - setAnnotations(null); - } - }); - return () => { - cancelled = true; - }; - }, [activeItemId, activeItemKind, specificationId, patchListState.lastBatchId]); - - useEffect(() => { - if (activeItemId === undefined || annotations === null) return; - if ( - annotations.itemId !== activeItemId || - annotations.itemKind !== activeItemKind || - annotations.batchId !== patchListState.lastBatchId - ) { - return; - } - const annotationIdsForActiveItem = new Set( - annotations.items - .filter((annotation) => annotation.knowledge_item_id === activeItemId) - .map((annotation) => annotation.id), - ); - setActiveCards((prev) => prev.filter((card) => annotationIdsForActiveItem.has(card.id))); - }, [activeItemId, activeItemKind, annotations, patchListState.lastBatchId]); - - const existingAnnotations: readonly SideChatExistingAnnotation[] = activeSideChat - ? (annotations?.items ?? []) - .filter((annotation) => annotation.knowledge_item_id === activeSideChat.itemId) - .map((annotation) => ({ - id: annotation.id, - summary: annotation.summary, - body: annotation.body, - })) - : []; - - const promoteAnnotation = useCallback((annotationId: number) => { - const annotation = (annotationsRef.current?.items ?? []).find((a) => a.id === annotationId); - const active = activeRef.current; - if (!annotation || !active) return; - setActiveCards((prev) => { - if (prev.some((card) => card.id === annotationId)) return prev; - return [ - ...prev, - { - id: annotation.id, - itemKind: active.itemKind, - referenceCode: active.pinnedItem.referenceCode, - summary: annotation.summary, - body: annotation.body, - timestamp: Date.now(), - }, - ]; - }); - }, []); - const sideChatContextValue = useMemo( - () => ({ - openFor, - openWithSpanHint, - activeCardIds, - dismissCard, - clearSpanHint, - promoteAnnotation, - setMode, - }), - [openFor, openWithSpanHint, activeCardIds, dismissCard, clearSpanHint, promoteAnnotation, setMode], - ); - - const threadItems: readonly SideChatThreadItem[] = activeSideChat - ? (() => { - // Both messages and cards record wall-clock Date.now() timestamps, so a single - // chronological sort actually interleaves them correctly. Messages get their - // timestamp at append time and preserve it across streaming deltas; cards get - // theirs when promoted from a freshly-applied annotation. - const messageItems: SideChatThreadItem[] = activeSideChat.messages.map((message, index) => ({ - kind: 'message' as const, - id: `m-${index}`, - message, - timestamp: activeSideChat.messageTimestamps[index] ?? 0, - })); - const cardItems: SideChatThreadItem[] = activeCards.map((card, idx) => { - const indexFromEnd = activeCards.length - 1 - idx; - const inContext = indexFromEnd < MAX_ACTIVE_ANNOTATIONS; - return { - kind: 'card' as const, - id: `c-${card.id}`, - annotationId: card.id, - summary: card.summary, - body: card.body, - itemKind: card.itemKind, - referenceCode: card.referenceCode, - inContext, - timestamp: card.timestamp, - }; - }); - return [...messageItems, ...cardItems].sort((a, b) => a.timestamp - b.timestamp); - })() - : []; - - const docksContent = activeSideChat !== null && layout === 'docked'; - - return ( - - - -
- {children} -
- {activeSideChat && ( - 0 ? applyStagedForActive : undefined} - onUndo={patchList ? undoForActive : undefined} - onDiscardPatch={patchList?.discard} - existingAnnotations={existingAnnotations} - onPromoteAnnotation={promoteAnnotation} - activeAnnotationIds={activeCardIds} - layout={layout} - onLayoutChange={setLayout} - spanHint={pendingSpanHint} - onClearSpanHint={clearSpanHint} - /> - )} -
-
-
- ); -} diff --git a/src/client/components/side-chat-popover.tsx b/src/client/components/side-chat-popover.tsx deleted file mode 100644 index cab90b8f..00000000 --- a/src/client/components/side-chat-popover.tsx +++ /dev/null @@ -1,795 +0,0 @@ -import { - ArrowUp, - Check, - ChevronRight, - CornerDownRight, - Highlighter, - Link2, - Loader2, - NotebookPen, - PanelRight, - PencilLine, - PictureInPicture2, - Plus, - StickyNote, - Undo2, - X, -} from 'lucide-react'; -import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; - -import type { KnowledgeKind } from '@/shared/knowledge.js'; - -import { ActiveCard } from './active-card.js'; -import { DEFAULT_ACCENT, DiffPopover } from './diff-popover.js'; -import { ImpactChip } from './impact-chip.js'; -import { kindAccentHex } from './knowledge-card'; - -type StagedPatchKind = 'annotate' | 'edit' | 'edge' | 'drill-down'; - -const STAGED_KIND_LABEL: Record = { - annotate: 'note', - edit: 'edit', - edge: 'edge', - 'drill-down': 'drill', -}; - -function StagedKindChip({ kind, accent }: { kind: StagedPatchKind; accent: string }): React.ReactElement { - const Icon = - kind === 'annotate' - ? StickyNote - : kind === 'edit' - ? PencilLine - : kind === 'edge' - ? Link2 - : CornerDownRight; - return ( - - - {STAGED_KIND_LABEL[kind]} - - ); -} - -function useTypewriter(target: string, animate: boolean, charDelayMs = 15): string { - const [displayed, setDisplayed] = useState(target); - useEffect(() => { - if (!animate) { - if (displayed !== target) setDisplayed(target); - return; - } - if (displayed.length > target.length) { - setDisplayed(target); - return; - } - if (displayed.length === target.length) return; - const remaining = target.length - displayed.length; - const charsToAdd = remaining > 40 ? Math.ceil(remaining / 20) : 1; - const id = window.setTimeout(() => { - setDisplayed(target.slice(0, displayed.length + charsToAdd)); - }, charDelayMs); - return () => window.clearTimeout(id); - }, [target, displayed, animate, charDelayMs]); - return displayed; -} - -function MessageBubble({ message }: { message: SideChatMessage }) { - const animate = message.role === 'assistant' && !message.error && message.pending === true; - const displayed = useTypewriter(message.text, animate); - const baseClass = message.error - ? 'max-w-[85%] rounded-lg bg-red-50 px-3 py-1.5 text-sm text-red-900 ring-1 ring-red-200' - : message.role === 'user' - ? 'self-end max-w-[85%] rounded-lg bg-[rgba(0,0,0,0.03)] px-3 py-1.5 text-sm text-ink' - : 'max-w-[85%] rounded-lg px-3 py-1.5 text-sm whitespace-pre-wrap text-ink'; - return ( -
  • - {message.role === 'assistant' && !message.error ? displayed : message.text} -
  • - ); -} - -export interface SideChatPinnedItem { - referenceCode: string; - content: string; - kind?: KnowledgeKind; -} - -export interface SideChatMessage { - role: 'user' | 'assistant'; - text: string; - pending?: true; - error?: true; -} - -export interface SideChatStagedPatchSummary { - id: string; - kind: StagedPatchKind; - summary: string; - // For kind='edit' only: server-classified impact tier rendered as a chip - // on the patch entry (design §4.1). - impact?: 'none' | 'soft' | 'hard'; - // For kind='edit' only: when both are present and differ, the row exposes - // a "View diff" chip that opens a with the word-level diff - // (Card 4, replaces the FE-665 inline
    expander). - currentContent?: string; - newContent?: string; -} - -export interface SideChatExistingAnnotation { - id: number; - summary: string; - body: string; -} - -export type SideChatThreadItem = - | { kind: 'message'; id: string; message: SideChatMessage; timestamp: number } - | { - kind: 'card'; - id: string; - annotationId: number; - summary: string; - body: string; - itemKind: KnowledgeKind; - referenceCode: string; - inContext: boolean; - timestamp: number; - }; - -export interface SideChatPopoverProps { - pinnedItem: SideChatPinnedItem; - onDismiss: () => void; - threadItems?: readonly SideChatThreadItem[]; - onSubmit?: (message: string) => void; - onDismissCard?: (annotationId: number) => void; - // ---- Annotate (Card C) ---- - annotateMode?: boolean; - onAnnotateRequest?: () => void; - onAnnotateCancel?: () => void; - onAnnotateSubmit?: (summary: string, body: string) => void; - // ---- Edit mode toggle (V2 chat-driven Edit) ---- - mode?: 'explore' | 'edit'; - onModeChange?: (mode: 'explore' | 'edit') => void; - // ---- Inline patch list (Card C, secondary surface per design §4) ---- - stagedPatches?: readonly SideChatStagedPatchSummary[]; - canUndo?: boolean; - isApplying?: boolean; - onApply?: () => void; - onUndo?: () => void; - onDiscardPatch?: (id: string) => void; - // ---- Existing annotations on the pinned item ---- - existingAnnotations?: readonly SideChatExistingAnnotation[]; - // ---- Promote-from-drawer (deferred §8 from the design spec) ---- - onPromoteAnnotation?: (annotationId: number) => void; - activeAnnotationIds?: readonly number[]; - // ---- Layout (docked = full-height right; floating = Gmail-style bottom-right) ---- - layout?: 'docked' | 'floating'; - onLayoutChange?: (layout: 'docked' | 'floating') => void; - // ---- Span hint chip (Chat path V1.2-E) ---- - spanHint?: string | null; - onClearSpanHint?: () => void; -} - -export function SideChatPopover({ - pinnedItem, - onDismiss, - threadItems = [], - onSubmit, - onDismissCard, - annotateMode = false, - onAnnotateRequest, - onAnnotateCancel, - onAnnotateSubmit, - mode = 'explore', - onModeChange, - stagedPatches = [], - canUndo = false, - isApplying = false, - onApply, - onUndo, - onDiscardPatch, - existingAnnotations = [], - onPromoteAnnotation, - activeAnnotationIds, - layout = 'docked', - onLayoutChange, - spanHint = null, - onClearSpanHint, -}: SideChatPopoverProps) { - const messagesForState: readonly SideChatMessage[] = threadItems.flatMap((item) => - item.kind === 'message' ? [item.message] : [], - ); - const [draft, setDraft] = useState(''); - const [annotateSummary, setAnnotateSummary] = useState(''); - const [annotateBody, setAnnotateBody] = useState(''); - const [notesOpen, setNotesOpen] = useState(false); - // Card 4 / S2: staged-patch row diff is shown via an anchored DiffPopover - // triggered by the per-row "↗ view diff" chip. We track which patch id is - // currently open and the chip element it's anchored to. - const [diffPopoverPatchId, setDiffPopoverPatchId] = useState(null); - const diffAnchorRef = useRef(null); - - useEffect(() => { - if (diffPopoverPatchId === null) return; - if (!stagedPatches.some((patch) => patch.id === diffPopoverPatchId)) { - setDiffPopoverPatchId(null); - diffAnchorRef.current = null; - } - }, [stagedPatches, diffPopoverPatchId]); - - const containerRef = useRef(null); - const messageInputRef = useRef(null); - const annotateSummaryRef = useRef(null); - - useEffect(() => { - if (annotateMode) { - annotateSummaryRef.current?.focus(); - } else { - setAnnotateSummary(''); - setAnnotateBody(''); - messageInputRef.current?.focus(); - } - }, [annotateMode]); - - // Card 4 follow-up: the "Change saved" toast moved out of the side-chat - // composer (where it overlapped the input row) into so - // it sits with the staged-changes / pending-review surfaces just under the - // kind chips. The popover no longer tracks toast visibility. - - useEffect(() => { - function handleEscape(event: globalThis.KeyboardEvent) { - if (event.key === 'Escape') { - // The DiffPopover handles its own ESC at capture phase and stops - // propagation, so this only fires when no popover is open. - event.preventDefault(); - if (annotateMode && onAnnotateCancel) { - onAnnotateCancel(); - } else { - onDismiss(); - } - } - } - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [annotateMode, onAnnotateCancel, onDismiss]); - - const trimmedDraft = draft.trim(); - const isStreaming = messagesForState.some((message) => message.pending === true); - const sendDisabled = trimmedDraft.length === 0 || isStreaming; - - const trimmedAnnotateSummary = annotateSummary.trim(); - const trimmedAnnotateBody = annotateBody.trim(); - const annotateSubmitDisabled = - trimmedAnnotateSummary.length === 0 || trimmedAnnotateBody.length === 0 || isApplying; - - function submit() { - if (sendDisabled || !onSubmit) { - return; - } - onSubmit(trimmedDraft); - setDraft(''); - } - - function handleInputKeyDown(event: KeyboardEvent) { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - submit(); - } - } - - function submitAnnotate() { - if (annotateSubmitDisabled || !onAnnotateSubmit) { - return; - } - onAnnotateSubmit(trimmedAnnotateSummary, trimmedAnnotateBody); - setAnnotateSummary(''); - setAnnotateBody(''); - } - - const annotateButtonDisabled = isStreaming || annotateMode; - const kindAccent = pinnedItem.kind ? kindAccentHex[pinnedItem.kind] : null; - const accent = kindAccent ?? DEFAULT_ACCENT; - const isEditMode = mode === 'edit'; - const editToggleDisabled = !onModeChange; - const activeDiffPatch = diffPopoverPatchId - ? (stagedPatches.find((p) => p.id === diffPopoverPatchId) ?? null) - : null; - - return ( -
    -
    -
    -
    - - {pinnedItem.referenceCode} - -

    {pinnedItem.content}

    -
    - -
      - {threadItems.map((item) => - item.kind === 'message' ? ( - - ) : ( - {})} - /> - ), - )} -
    - - {stagedPatches.length > 0 ? ( -
    -
    - - {stagedPatches.length} pending change{stagedPatches.length === 1 ? '' : 's'} - -
    -
      - {stagedPatches.map((patch) => { - const hasDiff = - patch.kind === 'edit' && - typeof patch.currentContent === 'string' && - typeof patch.newContent === 'string' && - patch.currentContent !== patch.newContent; - return ( -
    • { - (event.currentTarget as HTMLLIElement).style.backgroundColor = `${accent}05`; - }} - onMouseLeave={(event) => { - (event.currentTarget as HTMLLIElement).style.backgroundColor = 'transparent'; - }} - > - - - {patch.summary} - - {hasDiff ? ( - - ) : null} - {patch.kind === 'edit' && patch.impact ? : null} - {onDiscardPatch ? ( - - ) : null} -
    • - ); - })} -
    -
    - {isApplying ? ( - - Saving change… - - ) : null} - {canUndo && onUndo ? ( - - ) : null} - {onApply ? ( - - ) : null} -
    -
    - ) : isApplying ? ( -
    - Saving change… -
    - ) : null} - - {annotateMode ? ( -
    { - event.preventDefault(); - submitAnnotate(); - }} - className="flex flex-col gap-2 rounded-md bg-white p-2 shadow-[0_4px_4px_-2px_rgba(0,0,0,0.02),0_2px_2px_-1px_rgba(0,0,0,0.02),0_0_0_1px_rgba(0,0,0,0.08)]" - > - setAnnotateSummary(event.target.value)} - className="rounded-md bg-[#fafafa] px-2 py-1.5 text-sm text-ink outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" - /> -