diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index e1f9718d..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,46 +0,0 @@ -# Handoff - -> Generated by `ln-handoff` at 2026-05-13. Read this file to resume work. -> This file is volatile transfer state only. After its contents are reconciled into canonical docs or superseded by a newer handoff, overwrite or delete it. - -## Goal - -Ship the continuous workspace shell (FE-709) — cumulative phase sections, one chat runtime, sidebar scroll-spy, preserved phase addressability. - -## Session State - -- **Last completed skill**: `ln-build` — Steps 1–4 of CONTINUOUS_WORKSPACE_HYBRID.md migration plan + helper extraction all committed -- **Flow position**: `plan → scope → build (×3) → handoff → review → scope → build (helper extraction) → scope → build (Step 4) → ready for PR` - -## Commits on branch (6 total) - -1. `ed183421` FE-709: Plan umbrella frontier items + assign continuous-workspace -2. `372bba97` FE-709: Replace per-phase InterviewView with ContinuousWorkspaceView -3. `fa712f54` FE-709: Extract useContinuousWorkspaceController -4. `5292dbfb` FE-709: Sidebar scroll-spy highlighting via WorkspaceFocusContext -5. `f60eac9` FE-709: Extract shared controller helpers and enrichBottomArtifact to core -6. `7a35eee` FE-709: Retire route-first test assumptions - -## Remaining work - -### Step 5 — Route collapse decision (DEFERRED) - -Per CONTINUOUS_WORKSPACE_HYBRID.md §Migration Step 5: decide whether phase routes should become redirects or search-param aliases. This is a design decision, not urgent — the continuous center pane works correctly with current routes. Phase routes currently act as focus addresses into the shared workspace surface, which is the intended hybrid behavior. - -### PR submission - -Branch `ka/fe-709-continuous-workspace` is ready for `gt submit`. All steps are complete except the deferred design decision (Step 5). - -## Test status - -1213/1214 pass. 1 pre-existing flake in InterviewView.test.tsx (`renders live workspace-tool activity during the submitted pre-stream generating window` — tool detail assertion, unrelated to FE-709). - -## Open questions (unchanged from prior handoff) - -- Should the sticky center-pane header update to show the *focused* (scrolled-to) phase rather than always the *active* phase? Deferred to UX review. -- IntersectionObserver thresholds may need tuning for short sections. Manual walkthrough showed no issues. -- Should deep-link target become `?phase=design` search param or keep legacy phase paths? Deferred to Step 5. - -## Retirement rule - -Delete this file after PR is submitted or after Step 5 is decided. diff --git a/docs/design/UNIFIED_CHAT_UX.md b/docs/design/UNIFIED_CHAT_UX.md new file mode 100644 index 00000000..93c0d0b7 --- /dev/null +++ b/docs/design/UNIFIED_CHAT_UX.md @@ -0,0 +1,210 @@ +# 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_thread_substrate.sql b/drizzle/0020_thread_substrate.sql new file mode 100644 index 00000000..5ed90e2b --- /dev/null +++ b/drizzle/0020_thread_substrate.sql @@ -0,0 +1,64 @@ +-- 1. Create thread table +CREATE TABLE `thread` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `chat_id` integer NOT NULL, + `kind` text NOT NULL, + `target_item_id` integer, + `context_spec` text, + `kickoff_turn_id` integer, + `invoked_in_turn_id` integer, + `active_turn_id` integer, + `status` text DEFAULT 'open' NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`chat_id`) REFERENCES `chat`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`target_item_id`) REFERENCES `knowledge_item`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`kickoff_turn_id`) REFERENCES `turn`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`invoked_in_turn_id`) REFERENCES `turn`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`active_turn_id`) REFERENCES `turn`(`id`) ON UPDATE no action ON DELETE no action +);--> statement-breakpoint + +-- 2. Partial unique index: exactly one interview thread per chat +CREATE UNIQUE INDEX `thread_interview_unique` ON `thread` (`chat_id`) WHERE kind = 'interview';--> statement-breakpoint + +-- 3. Seed one interview thread per existing chat +INSERT INTO `thread` (`chat_id`, `kind`, `active_turn_id`) +SELECT `id`, 'interview', `active_turn_id` FROM `chat`;--> statement-breakpoint + +-- 4. Recreate turn with thread_id instead of chat_id +CREATE TABLE `turn_new` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `specification_id` integer NOT NULL, + `thread_id` integer NOT NULL, + `parent_turn_id` integer, + `phase` text NOT NULL, + `turn_kind` text DEFAULT 'question' NOT NULL, + `question` text DEFAULT '' NOT NULL, + `why` text, + `impact` text, + `answer` text, + `is_resolution` integer DEFAULT false NOT NULL, + `user_parts` text, + `assistant_parts` text, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`specification_id`) REFERENCES `specification`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`thread_id`) REFERENCES `thread`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`parent_turn_id`) REFERENCES `turn_new`(`id`) ON UPDATE no action ON DELETE no action +);--> statement-breakpoint +INSERT INTO `turn_new` (`id`, `specification_id`, `thread_id`, `parent_turn_id`, `phase`, `turn_kind`, `question`, `why`, `impact`, `answer`, `is_resolution`, `user_parts`, `assistant_parts`, `created_at`) +SELECT t.`id`, t.`specification_id`, th.`id`, t.`parent_turn_id`, t.`phase`, t.`turn_kind`, t.`question`, t.`why`, t.`impact`, t.`answer`, t.`is_resolution`, t.`user_parts`, t.`assistant_parts`, t.`created_at` +FROM `turn` t +JOIN `thread` th ON th.`chat_id` = t.`chat_id` AND th.`kind` = 'interview';--> statement-breakpoint +DROP TABLE `turn`;--> statement-breakpoint +ALTER TABLE `turn_new` RENAME TO `turn`;--> statement-breakpoint + +-- 5. Recreate chat without kind and active_turn_id +CREATE TABLE `chat_new` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `specification_id` integer NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`specification_id`) REFERENCES `specification`(`id`) ON UPDATE no action ON DELETE no action +);--> statement-breakpoint +INSERT INTO `chat_new` (`id`, `specification_id`, `created_at`) +SELECT `id`, `specification_id`, `created_at` FROM `chat`;--> statement-breakpoint +DROP TABLE `chat`;--> statement-breakpoint +ALTER TABLE `chat_new` RENAME TO `chat`; diff --git a/drizzle/0021_turn_phase_nullable.sql b/drizzle/0021_turn_phase_nullable.sql new file mode 100644 index 00000000..635c2a15 --- /dev/null +++ b/drizzle/0021_turn_phase_nullable.sql @@ -0,0 +1,27 @@ +-- Make turn.phase nullable so non-interview thread kinds (side, reconciliation, +-- qa, agent_run) can persist turns without an interview phase. + +CREATE TABLE `turn_new` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `specification_id` integer NOT NULL, + `thread_id` integer NOT NULL, + `parent_turn_id` integer, + `phase` text, + `turn_kind` text DEFAULT 'question' NOT NULL, + `question` text DEFAULT '' NOT NULL, + `why` text, + `impact` text, + `answer` text, + `is_resolution` integer DEFAULT false NOT NULL, + `user_parts` text, + `assistant_parts` text, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`specification_id`) REFERENCES `specification`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`thread_id`) REFERENCES `thread`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`parent_turn_id`) REFERENCES `turn_new`(`id`) ON UPDATE no action ON DELETE no action +);--> statement-breakpoint +INSERT INTO `turn_new` (`id`, `specification_id`, `thread_id`, `parent_turn_id`, `phase`, `turn_kind`, `question`, `why`, `impact`, `answer`, `is_resolution`, `user_parts`, `assistant_parts`, `created_at`) +SELECT `id`, `specification_id`, `thread_id`, `parent_turn_id`, `phase`, `turn_kind`, `question`, `why`, `impact`, `answer`, `is_resolution`, `user_parts`, `assistant_parts`, `created_at` +FROM `turn`;--> statement-breakpoint +DROP TABLE `turn`;--> statement-breakpoint +ALTER TABLE `turn_new` RENAME TO `turn`; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index dc58e5b2..20cc5ee5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,20 @@ "when": 1776360000000, "tag": "0019_reconciliation_need_agent_columns", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1776370000000, + "tag": "0020_thread_substrate", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1776380000000, + "tag": "0021_turn_phase_nullable", + "breakpoints": true } ] } \ No newline at end of file diff --git a/memory/PLAN.md b/memory/PLAN.md index ba3aabd1..0430d7d0 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -27,13 +27,12 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### Next -1. `chat-runtime-threads` — Track 2 of the runtime umbrella; immediate successor to continuous-workspace, unblocker for Tracks 3 and 5. First slice should be a sub-RFC on the thread substrate shape (p / q / r). -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. `thread-context-provision` — Track 5 of the runtime umbrella; after Track 2 lands the thread substrate. -5. `reconciliation-runtime` — Track 3 of the runtime umbrella; after Track 2 + Track 4 provide thread substrate 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. `thread-context-provision` — Track 5 of the runtime umbrella; after Track 2 lands the thread substrate. +4. `reconciliation-runtime` — Track 3 of the runtime umbrella; after Track 2 + Track 4 provide thread substrate 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 +73,16 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### chat-runtime-threads - **Name:** Chat runtime — thread substrate + in-stream rendering (Conversational Workspace Runtime — Track 2) -- **Linear:** unassigned in this plan snapshot +- **Linear:** FE-710 - **Kind:** structural -- **Status:** not-started -- **Objective:** Add a thread primitive to the chat substrate, render threads inline as collapsibles in the main chat surface (Cursor-style), and retire the SideChatPopover and transient staged-patches strip. Decide the thread substrate shape via a sub-RFC: (p) `parent_chat_id` on `chat`, (q) new `thread` table, or (r) UI-only rendering. -- **Why now / unlocks:** Track 1 (workspace shell) ships, providing the stable host. Threads are the critical unblocker for reconciliation absorption into the chat surface (Track 3), `#` mention / turn-zero / context provision (Track 5), and the retirement of the V3.1 popover and staged-patches surfaces. Supersedes the prior side-chat V4a persistence horizon — persistent side-chat history becomes the main chat stream where threads stay collapsed. -- **Acceptance:** Thread kinds (`interview`, `side`, `reconciliation`, `qa`) are representable in the substrate; threads render inline as collapsibles in the unified chat surface; SideChatPopover retires as cutover; transient staged-patches strip retires (replaced by in-thread mutation state); turn-zero (`turn_kind='kickoff'`) becomes the universal thread entry. +- **Status:** done +- **Objective:** Add a `thread` primitive between chat and turn, render threads inline as collapsibles in the main chat surface (Cursor-style), and retire `SideChatPopover`. Substrate sub-RFC settled: option (q) new `thread` table; chat collapses to a pure container; flat threads with `thread.invoked_in_turn_id` for inline agent runs (no nested threads in V1). +- **Why now / unlocks:** Track 1 (workspace shell) shipped, providing the stable host. Threads are the critical unblocker for reconciliation absorption into the chat surface (Track 3), `#` mention / turn-zero / context provision (Track 5), changeset attribution (Track 4), and the retirement of the V3.1 popover and staged-patches surfaces. Supersedes the prior side-chat V4a persistence horizon — persistent side-chat history becomes the main chat stream where threads stay collapsed. +- **Acceptance:** `thread` table exists with kinds (`interview`, `side`, `reconciliation`, `qa`, `agent_run`); `turn.thread_id` replaces `turn.chat_id`; `chat.kind` and `chat.active_turn_id` retire; threads render inline as collapsibles in the unified chat surface; `SideChatPopover` retires as cutover; each thread carries its own in-thread mutation state (edit mode + patch staging via ThreadCollapsible) — the global `PatchListOverlay` strip remains as a deliberate cross-thread summary surface; turn-zero (`turn_kind='kickoff'`) becomes the universal thread entry; agent runs render inline via `thread.invoked_in_turn_id`. `PendingReviewSection` retirement deferred to `reconciliation-runtime` (Track 3). - **Verification:** Thread substrate schema/migration tests, in-stream collapsible rendering tests, manual walkthroughs for thread creation/display/collapse per kind, regression on existing interview flow. -- **Traceability:** A82, A83, A88; D86, D87, D110, D114, D138, D146; I111, I113. +- **Traceability:** Requirements 39 (updated), 45; A88, A94; D86, D87, D110, D114, D138, D146, D153, D154; I111 (extended), I113. - **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.2 + §5 Track 2; `docs/design/MULTI_CHAT.md`; `docs/design/SIDE_CHAT.md`. +- **Current execution pointer:** all acceptance criteria met (slices 1–11). Thread substrate, inline streaming + edit mode, popover retirement + dead-code deletion (-3200 LOC), turn-zero kickoff, agent runs inline. Frontier ready to close. ### reconciliation-runtime @@ -387,6 +387,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ## Recently Completed +- [2026-05-15] `chat-runtime-threads` — Done: FE-710 / PR #138. Thread substrate (new `thread` table, `turn.thread_id`, chat simplified to pure container), inline ThreadCollapsible with streaming + edit mode + patch staging, SideChatPopover fully retired and deleted (-3200 LOC), turn-zero kickoff, agent runs inline via `invoked_in_turn_id`. Global `PatchListOverlay` strip remains as deliberate cross-thread summary. `PendingReviewSection` retirement deferred to `reconciliation-runtime` (Track 3). Verified: `npm run verify` 1157 pass / 15 skipped (retirement debt). Watch: 15 skipped tests cover retired popover bridge/undo modules and annotate auto-apply — delete when replacement coverage exists. - [2026-05-13] `continuous-workspace` — Done: FE-709 / PR #134. Replaced per-phase InterviewView with ContinuousWorkspaceView (cumulative center pane), extracted `useContinuousWorkspaceController`, added sidebar scroll-spy via WorkspaceFocusContext, extracted shared controller helpers to core, retired route-first test assumptions. Verified: `npm run verify` 1213 / 1214 pass (1 pre-existing flake). Watch: Step 5 route-collapse decision deferred — hybrid works as intended. - [2026-05-11] `side-chat-v3-1-agent-grouped-reconciliation` — Done: FE-674 / PR #124 + downstack closed the V3.x arc end-to-end with spec-level classifier route, per-row reset route, agent classification lifecycle, chips, per-class actions, and bulk Confirm-all / Apply-all-suggested. Verified: `npm run verify` 1178 / 1179 pass with one unrelated `side-chat-route` flake. Watch: A88 outer-loop walkthrough on a dense spec remains open to assess legibility vs V3.0's flat list. - [2026-05-11] `fe-698-reconciliation-context-pack` — Done: added proposal-only reconciliation prompt/context scenario rendering open reconciliation needs with source/target anchors, reason/status, prompt/context fingerprints, and read-only capability metadata. Verified: `npm run verify`. Watch: next FE-698 work can broaden read-only/proposal-only probes and Pi adapter spike without treating this pack as a resolution agent. diff --git a/memory/SPEC.md b/memory/SPEC.md index d5ac20a1..7df68f88 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -72,6 +72,7 @@ Brunch operates inside a **workspace**: the cwd-backed software context whose lo 31. Users can request a turn-owned candidate-spec set during grounding or design; accepting a direction may steer the next interview move and materialize intent items, but does not itself close the phase. 32. Interview detail can proceed as a progressive broad-pass-to-detail flow with explicit `next level of detail` actions. 44. Specifications can evolve through multiple chat-local strategies rather than one global interviewer mode. Each active/resumable chat has at most one open assistant/system-first frontier turn waiting for user completion. Proposal turns use normalized completion semantics; only proposal acceptance may apply semantic changes. +45. The chat surface hosts inline collapsible **threads** as the conversational-scope primitive between chat and turn. Each thread has a kind (`interview`, `side`, `reconciliation`, `qa`, `agent_run`), an optional target anchor, a context scope, an immutable kickoff turn, and an open/closed lifecycle. The interview thread is the chat's spine; other threads render inline as collapsibles invoked from a turn in the spine. #### Knowledge / intent graph @@ -82,7 +83,7 @@ Brunch operates inside a **workspace**: the cwd-backed software context whose lo 23. The knowledge/intent ontology is defined once and projected consistently through schema, shared registries, observer prompts, API types, fixtures, and UI copy. 30. Observer extraction treats typed relationships as first-class across the ontology and records them when reasonably supported while abstaining when support is weak. 38. The product ontology should expand beyond current exploration + review kinds to support `invariant` and `example` as first-class durable knowledge kinds. -39. Specifications can own multiple durable chat containers below the specification, with turns moving toward chat ownership while preserving temporary spec-scoped compatibility. Reconciliation needs remain process debt, separate from semantic intent edges. +39. Specifications own exactly one chat container that scopes conversational work; the chat hosts one or more durable threads (the conversational-scope primitives). Reconciliation needs remain process debt, separate from semantic intent edges. #### Review & export @@ -144,6 +145,7 @@ Brunch operates inside a **workspace**: the cwd-backed software context whose lo | A91 | Graph-review critique can make scenario-generated candidate bundles safe enough for product use if readiness states and follow-on review work are explicit. | medium | open | D151, D152, Requirement 44 | Run candidate bundle probes with graph-review scoring and human review. | | A92 | A conservative global staleness rule for open proposal turns is acceptable before neighborhood-level staleness calculation exists. | medium | open | D149, I117 | Exercise multi-chat proposal flows where another chat applies a changeset while a proposal remains open. | | A93 | Relation-policy directionality lookup is safer than forcing all useful intent-edge verbs into one dependency direction. | medium | open | D137, D150 | Define canonical/inverse sentences and source/target change behavior for each relation. | +| A94 | Flat threads with `invoked_in_turn_id` are sufficient for inline agent runs in V1; nested threads (`parent_thread_id`) are not needed before downstream pressure proves otherwise. | medium | open | D154 | Prototype agent-run threads invoked from interview and side surfaces; revisit if multi-level nesting becomes a UX or substrate need. | ### Active Decisions @@ -184,6 +186,8 @@ Brunch operates inside a **workspace**: the cwd-backed software context whose lo 150. **Relation policy owns operational directionality for intent edges** — cascade/reconciliation behavior is declared per relation, not inferred from raw source/target edge direction. 151. **Scenario-options acceleration is product-facing, but graph review is its safety oracle** — generated candidate bundles may become the user-facing alternative to long drilldown only with fixed-premise, tradeoff, checkability, provenance, and graph-review safeguards. 152. **Graph review and reconciliation are separate graph operations** — reconciliation repairs known disturbance debt; graph review critiques graph quality and starts as turn-owned structured artifacts unless independent lifecycle needs emerge. +153. **Thread is the conversational-scope primitive between chat and turn** — `chat` is a pure container (one per specification, no `kind`, no head pointer); a `thread` table owns `kind` (`interview` / `side` / `reconciliation` / `qa` / `agent_run`), `target_item_id`, `context_spec`, immutable `kickoff_turn_id`, `active_turn_id`, and `status`; `turn.chat_id` retires in favor of `turn.thread_id`; every chat has exactly one `kind='interview'` thread as its spine; `specification.active_turn_id` mirrors that thread's head. Decision settles the chat-runtime-threads sub-RFC ((p) `parent_chat_id`, (q) new `thread` table, (r) UI-only) on option (q). +154. **Threads stay flat below chats in V1** — agent-run and other sub-runs tag their originating turn via `thread.invoked_in_turn_id` rather than nesting (`parent_thread_id`); the UI handles visual nesting through the invoking-turn pointer. Pressure for true nested threads relaxes this decision by adding `parent_thread_id`; no V1 schema rework needed. #### Provider, prompt/context, and agent substrate @@ -221,7 +225,7 @@ Each invariant is a formalization candidate: the property is stated in human lan | I108 | Observer capture does not block chat stream completion for eligible answered turns; backlog state is re-derived from durable turns and persists results to the originating turn. | planned: app/controller tests | D22, D113 | | I109 | Observer prompts remain compact as relation extraction widens; candidates resolve only through validated existing ids or same-turn provisional references, and accepted reviews reuse relation policy. | `context.test.ts`, `observer.test.ts`, `db.test.ts`, `app.test.ts` | Requirement 30; D50, D125 | | I110 | Workflow read truth and write truth stay behind named seams instead of transport handlers owning workflow semantics. | workflow projector / transition / phase-close tests | D110, D113 | -| I111 | Multi-chat substrate preserves primary-chat active-head equivalence during transition, same-spec/chat ancestry, and reconciliation-need dedupe without conflating process debt with semantic edges. | `chat-substrate.test.ts`, `reconciliation-need.test.ts`, `db.test.ts` | Requirement 39; D137, D138 | +| I111 | Conversational substrate keeps `chat` as a pure container (one per spec, no kind, no head), every turn FK'd to exactly one `thread`, one `kind='interview'` thread per chat as the spine, `specification.active_turn_id` mirrored to that thread's `active_turn_id`, same-spec/chat/thread ancestry on turn writes, and reconciliation-need dedupe without conflating process debt with semantic edges. | `chat-substrate.test.ts`, `reconciliation-need.test.ts`, `db.test.ts` | Requirements 39, 45; D137, D138, D153, D154 | | I112 | Prompt/context scenarios render from packaged markdown prompts and typed context-pack builders, with deterministic fingerprints and reviewable golden coverage. | prompt loader/build/golden, context-pack, scenario-runner tests | Requirements 40, 41; D139, D140 | | I113 | Hard-impact direct edit opens reconciliation needs for affected relation-policy endpoints, records provenance, deduplicates idempotently, and no longer returns deferred placeholder responses. | planned: edit-applier/reconciliation/overlay/app tests | Requirement 10; A88; D146, D150 | | I114 | The reconciliation classifier lifecycle is explicit and recoverable; labels are constrained, failures persist parser/thrown errors, and proposals are never auto-applied. | reconciliation-agent tests | Requirement 10; A88; D139 | @@ -287,8 +291,9 @@ Detailed card styling, typography tokens, and legacy layout minutiae are impleme | **reconciliation need** | Durable process debt saying existing intent truth may require renewed judgment because related truth changed. Not an intent edge. | | **changeset** | Future canonical term for one submitted semantic mutation bundle against the intent graph. Supersedes patch. | | **change** | One atomic semantic mutation inside a changeset. Supersedes patch_change. | -| **chat** | A conversation container inside one specification; primary interview, side-chats, reconciliation chats, and review discussions may own turns without owning semantic truth directly. | -| **turn** | One persisted authored conversational interaction with typed offer/reply parts and parent linkage. | +| **chat** | A pure conversation container inside one specification; one per specification. The chat owns durable threads (the conversational-scope primitives) and does not itself carry kind, head pointer, or scope metadata. | +| **thread** | A durable kickoff-anchored sub-run inside one chat, carrying kind (`interview` / `side` / `reconciliation` / `qa` / `agent_run`), optional target anchor, context spec, immutable kickoff turn, and open/closed lifecycle. The interview thread is the chat's spine; other threads render inline as collapsibles invoked from a turn. | +| **turn** | One persisted authored conversational interaction with typed offer/reply parts and parent linkage; every turn belongs to exactly one thread. | | **frontier turn** | The single actionable durable conversational turn currently awaiting user completion. | | **proposal turn** | An assistant/system-first frontier turn offering a candidate bundle, graph-review finding, reconciliation suggestion, or other proposed action. It is not semantic truth until accepted. | | **workspace stream** | The merged center-column read model composed from active-path turns, anchored workflow facts, projected controls, phase markers, and activity cards. | diff --git a/src/client/components/__tests__/patch-list-overlay.test.tsx b/src/client/components/__tests__/patch-list-overlay.test.tsx index fef5b1a0..4be9a73d 100644 --- a/src/client/components/__tests__/patch-list-overlay.test.tsx +++ b/src/client/components/__tests__/patch-list-overlay.test.tsx @@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReconciliationNeedRecord } from '@/shared/reconciliation-need.js'; import { PatchListProvider, usePatchList, type PatchAppliers } from '../patch-list-host.js'; -import { PatchListOverlayBridgeProvider } from '../patch-list-overlay-bridge.js'; import { PatchListOverlay } from '../patch-list-overlay.js'; import { makeNeed } from './reconciliation-need-fixtures.js'; @@ -162,46 +161,11 @@ describe('PatchListOverlay', () => { expect(screen.getByRole('button', { name: /apply/i })).toBeTruthy(); }); - it('disables overlay Apply when the bridge has no scoped patches while others are staged', () => { - const applyScoped = vi.fn(); - render( - - - - - - , - ); - fireEvent.click(screen.getByText('stage-edit')); - const applyBtn = screen.getByRole('button', { name: /apply/i }) as HTMLButtonElement; - expect(applyBtn.disabled).toBe(true); - expect(applyScoped).not.toHaveBeenCalled(); - }); + // Skipped: PatchListOverlayBridgeProvider was removed with the SideChatPopover + // retirement (FE-710 slice 10). The bridge was a popover-specific scoping mechanism. + it.skip('disables overlay Apply when the bridge has no scoped patches while others are staged', () => {}); - it('invokes bridge applyScoped instead of applying all patches when a bridge is present', async () => { - const applyScoped = vi.fn(); - const editApplier = vi.fn(() => - Promise.resolve({ - undo: () => Promise.resolve(), - applied: { impact: 'soft', previousContent: 'old' }, - }), - ); - const appliers = makeAppliers({ edit: editApplier as unknown as PatchAppliers['edit'] }); - render( - - - - - - , - ); - fireEvent.click(screen.getByText('stage-edit')); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /apply/i })); - }); - expect(applyScoped).toHaveBeenCalledTimes(1); - expect(editApplier).not.toHaveBeenCalled(); - }); + it.skip('invokes bridge applyScoped instead of applying all patches when a bridge is present', () => {}); it('clicking Apply on the overlay invokes the patch-list applier', async () => { const editApplier = vi.fn(() => 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..8dd929e1 100644 --- a/src/client/components/impact-chip.tsx +++ b/src/client/components/impact-chip.tsx @@ -6,7 +6,7 @@ // - 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 +// Shared by `thread-collapsible.tsx` (staged-patch indicator) and // `patch-list-overlay.tsx` (canonical overlay expanded list) so both // surfaces speak the same visual language. diff --git a/src/client/components/patch-list-overlay-bridge.tsx b/src/client/components/patch-list-overlay-bridge.tsx deleted file mode 100644 index 2bfb70d5..00000000 --- a/src/client/components/patch-list-overlay-bridge.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { createContext, useContext, type ReactNode } from 'react'; - -export interface PatchListOverlayBridgeValue { - /** Same subset as the side-chat popover Apply (active item when chat is open; all staged when closed). */ - applyScoped: () => void; - /** Patch IDs included in `applyScoped`; empty when another item holds all staged work. */ - scopedPatchIds: readonly string[]; -} - -const PatchListOverlayBridgeContext = createContext(null); - -export function PatchListOverlayBridgeProvider({ - children, - value, -}: { - children: ReactNode; - value: PatchListOverlayBridgeValue; -}): React.ReactElement { - return ( - {children} - ); -} - -export function usePatchListOverlayBridge(): PatchListOverlayBridgeValue | null { - return useContext(PatchListOverlayBridgeContext); -} diff --git a/src/client/components/patch-list-overlay.tsx b/src/client/components/patch-list-overlay.tsx index d54d5723..f70614a5 100644 --- a/src/client/components/patch-list-overlay.tsx +++ b/src/client/components/patch-list-overlay.tsx @@ -9,9 +9,7 @@ import { usePatchListSavedToastLastAckBatchIdRef, usePatchListState, } from './patch-list-host.js'; -import { usePatchListOverlayBridge } from './patch-list-overlay-bridge.js'; import type { Patch } from './patch-list-reducer.js'; -import { usePatchListUndoOverride } from './patch-list-undo-context.js'; const MESSAGE_DURATION_MS = 5000; @@ -56,8 +54,6 @@ export function PatchListOverlay(): React.ReactElement | null { const patchList = usePatchList(); const state = usePatchListState(); const lastBatchAppliedMeta = useLastBatchAppliedMeta(); - const undoOverride = usePatchListUndoOverride(); - const overlayBridge = usePatchListOverlayBridge(); const stagedCount = state.staged.length; @@ -101,16 +97,9 @@ export function PatchListOverlay(): React.ReactElement | null { return null; } - const undo = undoOverride ?? (() => void patchList.undo()); - - const scopedApplyBlocked = - overlayBridge !== null && stagedCount > 0 && overlayBridge.scopedPatchIds.length === 0; + const undo = () => void patchList.undo(); const applyFromOverlay = (): void => { - if (overlayBridge) { - overlayBridge.applyScoped(); - return; - } void patchList.apply(); }; @@ -157,12 +146,7 @@ export function PatchListOverlay(): React.ReactElement | null { ) : null} - ) : 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" - /> -