From 73081ce7036a52dfcd730981ad6932be00b88cf2 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 16:49:34 +0200 Subject: [PATCH 01/19] FE-710: Settle thread substrate as option (q) in SPEC/PLAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the chat-runtime-threads sub-RFC: chat collapses to a pure container, a new `thread` table sits between chat and turn carrying kind/target/context/lifecycle, and agent runs stay flat via `thread.invoked_in_turn_id` rather than nesting. Adds Req 45, D153, D154, A94; updates Req 39 and I111; extends the lexicon. Moves chat-runtime-threads to PLAN Active with FE-710 linkage and a substrate-landing execution pointer. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- memory/PLAN.md | 27 ++++++++++++++------------- memory/SPEC.md | 13 +++++++++---- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index ba3aabd1..535e3a43 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -24,16 +24,16 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### Active 1. `agent-fixture-substrate` β€” branch-complete off main, reconciling β€” FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes. +2. `chat-runtime-threads` β€” FE-710; Track 2 of the runtime umbrella; substrate sub-RFC settled on option (q) new `thread` table; first slice lands the substrate without UI cutover. ### 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 +74,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:** in-progress +- **Objective:** Add a `thread` primitive between chat and turn, render threads inline as collapsibles in the main chat surface (Cursor-style), and retire `SideChatPopover` and the transient staged-patches strip. 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; transient staged-patches strip retires (replaced by in-thread mutation state); turn-zero (`turn_kind='kickoff'`) becomes the universal thread entry; agent runs render inline via `thread.invoked_in_turn_id`. - **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:** substrate-landing slice β€” introduce `thread` table, auto-create main interview thread, migrate `turn.chat_id` β†’ `thread_id`, retire `chat.kind` / `chat.active_turn_id`. No UI cutover. ### reconciliation-runtime 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. | From 4d458034271e727793eefcd389dfc9843f0457bb Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 17:14:13 +0200 Subject: [PATCH 02/19] =?UTF-8?q?FE-710:=20Land=20thread=20substrate=20?= =?UTF-8?q?=E2=80=94=20migration,=20schema,=20store,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce thread table between chat and turn (D153 option q). Chat becomes a pure container (no kind, no active_turn_id). turn.chat_id β†’ turn.thread_id; partial unique index enforces one interview thread per chat. createSpecification atomically inserts spec + chat + interview thread. advanceHead mirrors to interview thread. All 1261 tests pass; npm run verify clean. Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d Co-authored-by: Amp --- HANDOFF.md | 76 +++++++++---- drizzle/0020_thread_substrate.sql | 64 +++++++++++ drizzle/meta/_journal.json | 7 ++ memory/PLAN.md | 2 +- src/server/capabilities.ts | 35 +++--- src/server/chat-substrate.test.ts | 161 ++++++++++++++++++--------- src/server/context.test.ts | 14 +-- src/server/db.ts | 6 +- src/server/db/specification-store.ts | 44 +++++--- src/server/schema.ts | 33 +++++- 10 files changed, 324 insertions(+), 118 deletions(-) create mode 100644 drizzle/0020_thread_substrate.sql diff --git a/HANDOFF.md b/HANDOFF.md index e1f9718d..d4023bda 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,46 +1,76 @@ # Handoff -> Generated by `ln-handoff` at 2026-05-13. Read this file to resume work. +> Generated by `ln-build` at 2026-05-14. 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. +Land the **thread substrate** for FE-710 (chat-runtime-threads) and advance to the next slice. ## 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` +- **Last completed skill**: `ln-build` β€” substrate-landing slice implemented and verified (1261/1261 tests, full `npm run verify` gate passed). +- **Before that**: prior session ran `ln-design` β†’ `ln-scope` β†’ `ln-spec` β†’ `ln-handoff`; produced the scope card, settled D153/D154/A94, opened PR #138 with the planning baseline. +- **Flow position**: `design β†’ scope β†’ spec β†’ handoff β†’ build (done) β†’ [scope next slice]` -## Commits on branch (6 total) +## What landed -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 +### Migration 0020 β€” thread substrate -## Remaining work +- `drizzle/0020_thread_substrate.sql` + journal entry +- Created `thread` table: `id`, `chat_id`, `kind` (interview/side/reconciliation/qa/agent_run), `target_item_id`, `context_spec`, `kickoff_turn_id`, `invoked_in_turn_id`, `active_turn_id`, `status`, `created_at` +- Partial unique index `thread_interview_unique` on `(chat_id) WHERE kind = 'interview'` +- Migrated `turn.chat_id` β†’ `turn.thread_id` (NOT NULL) +- Dropped `chat.kind` and `chat.active_turn_id` (chat is now a pure container) +- Data migration: seeded one interview thread per existing chat; mapped turn rows through thread join -### Step 5 β€” Route collapse decision (DEFERRED) +### Schema (`src/server/schema.ts`) -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. +- Added `thread` table definition with all columns and partial unique index +- Removed `kind` and `active_turn_id` from `chat` +- Changed `turn.chat_id` β†’ `turn.thread_id` (NOT NULL, FK to thread) -### PR submission +### Specification store (`src/server/db/specification-store.ts`) -Branch `ka/fe-709-continuous-workspace` is ready for `gt submit`. All steps are complete except the deferred design decision (Step 5). +- `insertSpecificationWithInterviewChat` now creates spec + chat + interview thread atomically +- Added `getInterviewThreadIdForSpecification` helper +- `createTurn` routes through interview thread (validates parent in same thread) +- `advanceHead` mirrors `active_turn_id` to interview thread (was chat) -## Test status +### Capabilities (`src/server/capabilities.ts`) -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). +- `getChatById` now joins through thread to source `kind` and `active_turn_id` from interview thread +- `getPrimaryChatFromCapability` delegates to updated `getChatById` +- `submitTurnResponseFromCapability` validates turn ownership through `specification_id` (was `chat_id`) -## Open questions (unchanged from prior handoff) +### Tests (`src/server/chat-substrate.test.ts`) -- 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. +- Full rewrite: 14 tests covering schema assertions, atomic quad creation, thread uniqueness, turn writes, head mirroring, atomicity, and read-path equivalence + +### Blast-radius fixes + +- `src/server/context.test.ts` β€” turn fixtures updated `chat_id: null` β†’ `thread_id: 0` + +## Decisions and assumptions + +| Item | Type | Status | Source | +| --- | --- | --- | --- | +| Eager interview-thread creation in createSpecification | assumption | validated | scope card β†’ code proves it | +| `chat.kind` and `chat.active_turn_id` retire entirely | decision | validated | grep clean; no readers remain | + +## Repo state + +- **Branch**: `ka/fe-710-chat-runtime-threads` +- **PR**: #138 (open, planning baseline only) +- **Dirty files**: all substrate changes uncommitted (ready for commit) +- **Test status**: 1261/1261 passing; `npm run verify` gate clean + +## Next steps + +1. Commit the substrate-landing slice on the current branch. +2. Scope the next slice: in-stream collapsible UI for non-interview thread kinds + `SideChatPopover` retirement cutover. This is still inside FE-710; same branch. +3. Manual walkthrough: `npm run dev`, create a spec, advance through a few grounding turns, reload, confirm continuity. ## Retirement rule -Delete this file after PR is submitted or after Step 5 is decided. +- Overwrite or delete this file once the next slice is scoped and the substrate commit is verified. 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/meta/_journal.json b/drizzle/meta/_journal.json index dc58e5b2..cefdf311 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1776360000000, "tag": "0019_reconciliation_need_agent_columns", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1776370000000, + "tag": "0020_thread_substrate", + "breakpoints": true } ] } \ No newline at end of file diff --git a/memory/PLAN.md b/memory/PLAN.md index 535e3a43..ffebb229 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -83,7 +83,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **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:** 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:** substrate-landing slice β€” introduce `thread` table, auto-create main interview thread, migrate `turn.chat_id` β†’ `thread_id`, retire `chat.kind` / `chat.active_turn_id`. No UI cutover. +- **Current execution pointer:** substrate-landing slice landed (migration 0020, schema/store/test rewrite). Next slice: in-stream collapsible UI for non-interview thread kinds, `SideChatPopover` retirement cutover. ### reconciliation-runtime diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 42d0e65a..974a9178 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -1,5 +1,5 @@ import { readUIMessageStream } from 'ai'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { submitTurnResponseRequestSchema } from '@/shared/api-types.js'; @@ -203,16 +203,7 @@ function getPrimaryChatFromCapability(db: DB, input: ChatGetPrimaryInput) { throw new CapabilityDispatchError(`Specification ${input.specId} has no primary chat`, 'handler_failed'); } - const chat = db - .select({ - id: schema.chat.id, - specification_id: schema.chat.specification_id, - kind: schema.chat.kind, - active_turn_id: schema.chat.active_turn_id, - }) - .from(schema.chat) - .where(eq(schema.chat.id, specification.primary_chat_id)) - .get(); + const chat = getChatById(db, specification.primary_chat_id); if (!chat || chat.specification_id !== input.specId) { throw new CapabilityDispatchError( @@ -230,16 +221,30 @@ function getPrimaryChatFromCapability(db: DB, input: ChatGetPrimaryInput) { } function getChatById(db: DB, chatId: number) { - return db + const chatRow = db .select({ id: schema.chat.id, specification_id: schema.chat.specification_id, - kind: schema.chat.kind, - active_turn_id: schema.chat.active_turn_id, }) .from(schema.chat) .where(eq(schema.chat.id, chatId)) .get(); + if (!chatRow) return undefined; + + const interviewThread = db + .select({ + kind: schema.thread.kind, + active_turn_id: schema.thread.active_turn_id, + }) + .from(schema.thread) + .where(and(eq(schema.thread.chat_id, chatId), eq(schema.thread.kind, 'interview'))) + .get(); + + return { + ...chatRow, + kind: interviewThread?.kind ?? ('interview' as const), + active_turn_id: interviewThread?.active_turn_id ?? null, + }; } const INITIAL_INTERVIEWER_PROMPT = 'Begin the grounding interview.'; @@ -373,7 +378,7 @@ function submitTurnResponseFromCapability(db: DB, input: TurnSubmitResponseInput if (!turn) { throw new CapabilityDispatchError(`Turn ${input.turnId} not found`, 'handler_failed'); } - if (turn.chat_id !== chat.id || turn.specification_id !== chat.specification_id) { + if (turn.specification_id !== chat.specification_id) { throw new CapabilityDispatchError( `Turn ${input.turnId} does not belong to chat ${input.chatId}`, 'handler_failed', diff --git a/src/server/chat-substrate.test.ts b/src/server/chat-substrate.test.ts index 1451228b..5e279005 100644 --- a/src/server/chat-substrate.test.ts +++ b/src/server/chat-substrate.test.ts @@ -12,23 +12,40 @@ afterEach(() => { db.$client.close(); }); -describe('chat container schema', () => { - it('chat table exists with expected columns', () => { - const columns = db.$client.prepare("PRAGMA table_info('chat')").all() as Array<{ name: string }>; +describe('thread substrate schema', () => { + it('thread table exists with expected columns', () => { + const columns = db.$client.prepare("PRAGMA table_info('thread')").all() as Array<{ name: string }>; const names = columns.map((c) => c.name); expect(names).toContain('id'); - expect(names).toContain('specification_id'); + expect(names).toContain('chat_id'); expect(names).toContain('kind'); + expect(names).toContain('target_item_id'); + expect(names).toContain('context_spec'); + expect(names).toContain('kickoff_turn_id'); + expect(names).toContain('invoked_in_turn_id'); expect(names).toContain('active_turn_id'); + expect(names).toContain('status'); expect(names).toContain('created_at'); }); - it('turn table has chat_id column', () => { + it('chat table has no kind or active_turn_id column', () => { + const columns = db.$client.prepare("PRAGMA table_info('chat')").all() as Array<{ name: string }>; + const names = columns.map((c) => c.name); + expect(names).toContain('id'); + expect(names).toContain('specification_id'); + expect(names).toContain('created_at'); + expect(names).not.toContain('kind'); + expect(names).not.toContain('active_turn_id'); + }); + + it('turn table has thread_id, not chat_id', () => { const columns = db.$client.prepare("PRAGMA table_info('turn')").all() as Array<{ name: string }>; - expect(columns.map((c) => c.name)).toContain('chat_id'); + const names = columns.map((c) => c.name); + expect(names).toContain('thread_id'); + expect(names).not.toContain('chat_id'); }); - it('specification table has primary_chat_id column', () => { + it('specification table still has primary_chat_id', () => { const columns = db.$client.prepare("PRAGMA table_info('specification')").all() as Array<{ name: string; }>; @@ -36,62 +53,84 @@ describe('chat container schema', () => { }); }); -describe('chat container β€” spec creation transactional', () => { - it('createSpecification inserts spec + interview chat in one transaction', () => { +describe('thread substrate β€” spec creation atomic quad', () => { + it('createSpecification inserts spec + chat + interview thread in one transaction', () => { const spec = createSpecification(db, 'Test'); const chats = db.$client - .prepare('SELECT id, specification_id, kind, active_turn_id FROM chat WHERE specification_id = ?') - .all(spec.id) as Array<{ + .prepare('SELECT id, specification_id FROM chat WHERE specification_id = ?') + .all(spec.id) as Array<{ id: number; specification_id: number }>; + expect(chats).toHaveLength(1); + expect(chats[0].specification_id).toBe(spec.id); + + const threads = db.$client + .prepare('SELECT id, chat_id, kind, active_turn_id, status FROM thread WHERE chat_id = ?') + .all(chats[0].id) as Array<{ id: number; - specification_id: number; + chat_id: number; kind: string; active_turn_id: number | null; + status: string; }>; - expect(chats).toHaveLength(1); - expect(chats[0].kind).toBe('interview'); - expect(chats[0].specification_id).toBe(spec.id); - expect(chats[0].active_turn_id).toBeNull(); + expect(threads).toHaveLength(1); + expect(threads[0].kind).toBe('interview'); + expect(threads[0].active_turn_id).toBeNull(); + expect(threads[0].status).toBe('open'); }); - it('spec.primary_chat_id points to the interview chat', () => { + it('spec.primary_chat_id points to the chat that owns the interview thread', () => { const spec = createSpecification(db, 'Test'); const reread = getSpecification(db, spec.id) as | (typeof spec & { primary_chat_id: number | null }) | undefined; expect(reread).toBeDefined(); - const interviewChat = db.$client - .prepare("SELECT id FROM chat WHERE specification_id = ? AND kind = 'interview'") - .get(spec.id) as { id: number }; - expect(reread?.primary_chat_id).toBe(interviewChat.id); + const interviewThread = db.$client + .prepare("SELECT chat_id FROM thread WHERE chat_id = ? AND kind = 'interview'") + .get(reread!.primary_chat_id) as { chat_id: number } | undefined; + expect(interviewThread).toBeDefined(); + expect(interviewThread!.chat_id).toBe(reread!.primary_chat_id); }); - it('every spec has exactly one interview chat', () => { + it('every spec has exactly one interview thread per chat', () => { createSpecification(db, 'Alpha'); createSpecification(db, 'Beta'); const counts = db.$client - .prepare( - "SELECT specification_id, COUNT(*) AS n FROM chat WHERE kind = 'interview' GROUP BY specification_id", - ) - .all() as Array<{ specification_id: number; n: number }>; + .prepare("SELECT chat_id, COUNT(*) AS n FROM thread WHERE kind = 'interview' GROUP BY chat_id") + .all() as Array<{ chat_id: number; n: number }>; expect(counts).toHaveLength(2); for (const row of counts) expect(row.n).toBe(1); }); -}); -describe('chat container β€” turn writes', () => { - it('createTurn populates chat_id from spec primary chat', () => { + it('partial unique index prevents a second interview thread on the same chat', () => { const spec = createSpecification(db, 'Test'); - const turn = createTurn(db, spec.id, { phase: 'grounding', question: 'Q1' }); - const row = db.$client.prepare('SELECT chat_id FROM turn WHERE id = ?').get(turn.id) as { - chat_id: number; - }; const reread = getSpecification(db, spec.id) as | (typeof spec & { primary_chat_id: number | null }) | undefined; - expect(row.chat_id).toBe(reread?.primary_chat_id); + expect(() => { + db.$client + .prepare("INSERT INTO thread (chat_id, kind, status) VALUES (?, 'interview', 'open')") + .run(reread!.primary_chat_id); + }).toThrow(); + }); +}); + +describe('thread substrate β€” turn writes', () => { + it('createTurn populates thread_id from the interview thread', () => { + const spec = createSpecification(db, 'Test'); + const turn = createTurn(db, spec.id, { phase: 'grounding', question: 'Q1' }); + const row = db.$client.prepare('SELECT thread_id FROM turn WHERE id = ?').get(turn.id) as { + thread_id: number; + }; + const interviewThread = db.$client + .prepare( + `SELECT t.id FROM thread t + JOIN chat c ON c.id = t.chat_id + WHERE c.specification_id = ? AND t.kind = 'interview'`, + ) + .get(spec.id) as { id: number }; + expect(row.thread_id).toBe(interviewThread.id); }); - it('createTurn rejects parent that lives in a different chat', () => { + it('createTurn rejects parent that lives in a different thread', () => { const spec = createSpecification(db, 'Test'); const otherSpec = createSpecification(db, 'Other'); const otherTurn = createTurn(db, otherSpec.id, { phase: 'grounding', question: 'Other Q' }); @@ -105,18 +144,22 @@ describe('chat container β€” turn writes', () => { }); }); -describe('chat container β€” head mirroring', () => { - it('advanceHead mirrors active_turn_id to the interview chat', () => { +describe('thread substrate β€” head mirroring', () => { + it('advanceHead mirrors active_turn_id to the interview thread', () => { const spec = createSpecification(db, 'Test'); const turn = createTurn(db, spec.id, { phase: 'grounding', question: 'Q1' }); advanceHead(db, spec.id, turn.id); const row = db.$client - .prepare("SELECT active_turn_id FROM chat WHERE specification_id = ? AND kind = 'interview'") + .prepare( + `SELECT t.active_turn_id FROM thread t + JOIN chat c ON c.id = t.chat_id + WHERE c.specification_id = ? AND t.kind = 'interview'`, + ) .get(spec.id) as { active_turn_id: number }; expect(row.active_turn_id).toBe(turn.id); }); - it('spec.active_turn_id and interview chat.active_turn_id stay in sync across advances', () => { + it('spec.active_turn_id and interview thread.active_turn_id stay in sync across advances', () => { const spec = createSpecification(db, 'Test'); const t1 = createTurn(db, spec.id, { phase: 'grounding', question: 'Q1' }); advanceHead(db, spec.id, t1.id); @@ -128,16 +171,20 @@ describe('chat container β€” head mirroring', () => { advanceHead(db, spec.id, t2.id); const reread = getSpecification(db, spec.id); - const chatHead = db.$client - .prepare("SELECT active_turn_id FROM chat WHERE specification_id = ? AND kind = 'interview'") + const threadHead = db.$client + .prepare( + `SELECT t.active_turn_id FROM thread t + JOIN chat c ON c.id = t.chat_id + WHERE c.specification_id = ? AND t.kind = 'interview'`, + ) .get(spec.id) as { active_turn_id: number }; expect(reread?.active_turn_id).toBe(t2.id); - expect(chatHead.active_turn_id).toBe(reread?.active_turn_id ?? null); + expect(threadHead.active_turn_id).toBe(reread?.active_turn_id ?? null); }); }); -describe('chat container β€” head mirroring atomicity', () => { - it('rolls back the spec head if the interview chat row is missing', () => { +describe('thread substrate β€” head mirroring atomicity', () => { + it('rolls back the spec head if the interview thread row is missing', () => { const spec = createSpecification(db, 'Test'); const t1 = createTurn(db, spec.id, { phase: 'grounding', question: 'Q1' }); advanceHead(db, spec.id, t1.id); @@ -147,11 +194,16 @@ describe('chat container β€” head mirroring atomicity', () => { parent_turn_id: t1.id, }); - const reread = getSpecification(db, spec.id) as - | (typeof spec & { primary_chat_id: number | null }) - | undefined; + // Delete the interview thread to simulate corruption + const interviewThread = db.$client + .prepare( + `SELECT t.id FROM thread t + JOIN chat c ON c.id = t.chat_id + WHERE c.specification_id = ? AND t.kind = 'interview'`, + ) + .get(spec.id) as { id: number }; db.$client.exec('PRAGMA foreign_keys = OFF'); - db.$client.prepare('DELETE FROM chat WHERE id = ?').run(reread?.primary_chat_id); + db.$client.prepare('DELETE FROM thread WHERE id = ?').run(interviewThread.id); db.$client.exec('PRAGMA foreign_keys = ON'); expect(() => advanceHead(db, spec.id, t2.id)).toThrow(); @@ -161,21 +213,22 @@ describe('chat container β€” head mirroring atomicity', () => { }); }); -describe('chat container β€” read-path equivalence', () => { - it('spec.active_turn_id equals spec.primary_chat β†’ chat.active_turn_id', () => { +describe('thread substrate β€” read-path equivalence', () => { + it('spec.active_turn_id equals interview thread.active_turn_id', () => { const spec = createSpecification(db, 'Test'); const t1 = createTurn(db, spec.id, { phase: 'grounding', question: 'Q1' }); advanceHead(db, spec.id, t1.id); const row = db.$client .prepare( - `SELECT s.active_turn_id AS legacy, c.active_turn_id AS chat + `SELECT s.active_turn_id AS spec_head, t.active_turn_id AS thread_head FROM specification s JOIN chat c ON c.id = s.primary_chat_id + JOIN thread t ON t.chat_id = c.id AND t.kind = 'interview' WHERE s.id = ?`, ) - .get(spec.id) as { legacy: number; chat: number }; - expect(row.legacy).toBe(t1.id); - expect(row.chat).toBe(row.legacy); + .get(spec.id) as { spec_head: number; thread_head: number }; + expect(row.spec_head).toBe(t1.id); + expect(row.thread_head).toBe(row.spec_head); }); }); diff --git a/src/server/context.test.ts b/src/server/context.test.ts index ca93ac30..5541a50d 100644 --- a/src/server/context.test.ts +++ b/src/server/context.test.ts @@ -541,7 +541,7 @@ describe('observer-context-projection', () => { const turn: Turn = { id: 5, specification_id: 1, - chat_id: null, + thread_id: 0, parent_turn_id: 4, phase: 'grounding', turn_kind: 'question', @@ -578,7 +578,7 @@ describe('observer-context-projection', () => { const turn: Turn = { id: 5, specification_id: 1, - chat_id: null, + thread_id: 0, parent_turn_id: 4, phase: 'grounding', turn_kind: 'question', @@ -670,7 +670,7 @@ describe('observer-context-projection', () => { const turn: Turn = { id: 5, specification_id: 1, - chat_id: null, + thread_id: 0, parent_turn_id: 4, phase: 'grounding', turn_kind: 'question', @@ -710,7 +710,7 @@ describe('observer-context-projection', () => { const turn: Turn = { id: 5, specification_id: 1, - chat_id: null, + thread_id: 0, parent_turn_id: 4, phase: 'grounding', turn_kind: 'question', @@ -799,7 +799,7 @@ describe('observer-context-projection', () => { const turn: Turn = { id: 5, specification_id: 1, - chat_id: null, + thread_id: 0, parent_turn_id: 4, phase: 'grounding', turn_kind: 'question', @@ -845,7 +845,7 @@ describe('observer-context-projection', () => { const turn: Turn = { id: 6, specification_id: 1, - chat_id: null, + thread_id: 0, parent_turn_id: 5, phase: 'criteria', turn_kind: 'question', @@ -890,7 +890,7 @@ describe('observer-context-projection', () => { const turn: Turn = { id: 7, specification_id: 1, - chat_id: null, + thread_id: 0, parent_turn_id: 6, phase: 'design', turn_kind: 'question', diff --git a/src/server/db.ts b/src/server/db.ts index 3ef6d1ec..312ea1c4 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -145,8 +145,12 @@ export type DB = ReturnType>; export function createDb(path: string = ':memory:'): DB { const sqlite = new Database(path); sqlite.pragma('journal_mode = WAL'); - sqlite.pragma('foreign_keys = ON'); + // Foreign keys OFF during migration so table-recreation migrations + // (DROP TABLE + rename) don't hit FK constraint errors. The PRAGMA + // is a no-op inside a transaction, so it must be set before migrate(). + sqlite.pragma('foreign_keys = OFF'); const db = drizzle(sqlite, { schema }); migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + sqlite.pragma('foreign_keys = ON'); return db; } diff --git a/src/server/db/specification-store.ts b/src/server/db/specification-store.ts index 155a9e5e..bbc726ed 100644 --- a/src/server/db/specification-store.ts +++ b/src/server/db/specification-store.ts @@ -77,9 +77,10 @@ function insertSpecificationWithInterviewChat( const inserted = tx.insert(schema.specification).values(values).returning().get() as Specification; const chatRow = tx .insert(schema.chat) - .values({ specification_id: inserted.id, kind: 'interview' }) + .values({ specification_id: inserted.id }) .returning({ id: schema.chat.id }) .get(); + tx.insert(schema.thread).values({ chat_id: chatRow.id, kind: 'interview' }).run(); const updated = tx .update(schema.specification) .set({ primary_chat_id: chatRow.id }) @@ -102,6 +103,19 @@ function getInterviewChatIdForSpecification(db: DB, specificationId: number): nu return spec.primary_chat_id; } +function getInterviewThreadIdForSpecification(db: DB, specificationId: number): number { + const chatId = getInterviewChatIdForSpecification(db, specificationId); + const threadRow = db + .select({ id: schema.thread.id }) + .from(schema.thread) + .where(and(eq(schema.thread.chat_id, chatId), eq(schema.thread.kind, 'interview'))) + .get(); + if (!threadRow) { + throw new Error(`Specification ${specificationId} has no interview thread; substrate invariant violated`); + } + return threadRow.id; +} + export function getSpecification(db: DB, id: number): Specification | undefined { return db.select().from(schema.specification).where(eq(schema.specification.id, id)).get() as | Specification @@ -113,21 +127,21 @@ export function getTurn(db: DB, turnId: number): Turn | undefined { } export function createTurn(db: DB, specificationId: number, input: CreateTurnInput): Turn { - const chatId = getInterviewChatIdForSpecification(db, specificationId); + const threadId = getInterviewThreadIdForSpecification(db, specificationId); if (input.parent_turn_id != null) { const parent = db - .select({ chat_id: schema.turn.chat_id }) + .select({ thread_id: schema.turn.thread_id }) .from(schema.turn) .where(eq(schema.turn.id, input.parent_turn_id)) .get(); if (!parent) { throw new Error(`Parent turn ${input.parent_turn_id} not found`); } - if (parent.chat_id !== chatId) { + if (parent.thread_id !== threadId) { throw new Error( - `Parent turn ${input.parent_turn_id} lives in chat ${parent.chat_id}, ` + - `not chat ${chatId} β€” parent_turn_id must share chat_id with the new turn`, + `Parent turn ${input.parent_turn_id} lives in thread ${parent.thread_id}, ` + + `not thread ${threadId} β€” parent_turn_id must share thread with the new turn`, ); } } @@ -136,7 +150,7 @@ export function createTurn(db: DB, specificationId: number, input: CreateTurnInp .insert(schema.turn) .values({ specification_id: specificationId, - chat_id: chatId, + thread_id: threadId, parent_turn_id: input.parent_turn_id ?? null, phase: input.phase, turn_kind: input.turn_kind ?? 'question', @@ -241,20 +255,22 @@ export function applyTurnResponseSelections(db: DB, turnId: number, selectedPosi } export function advanceHead(db: DB, specificationId: number, turnId: number): void { - const chatId = getInterviewChatIdForSpecification(db, specificationId); + const threadId = getInterviewThreadIdForSpecification(db, specificationId); db.transaction((tx) => { tx.update(schema.specification) .set({ active_turn_id: turnId, updated_at: sql`datetime('now')` }) .where(eq(schema.specification.id, specificationId)) .run(); - const updatedChat = tx - .update(schema.chat) + const updatedThread = tx + .update(schema.thread) .set({ active_turn_id: turnId }) - .where(eq(schema.chat.id, chatId)) - .returning({ id: schema.chat.id }) + .where(eq(schema.thread.id, threadId)) + .returning({ id: schema.thread.id }) .get(); - if (!updatedChat) { - throw new Error(`Interview chat ${chatId} for spec ${specificationId} not found; head update aborted`); + if (!updatedThread) { + throw new Error( + `Interview thread ${threadId} for spec ${specificationId} not found; head update aborted`, + ); } }); reconcilePhaseOutcomesForSpecification(db, specificationId); diff --git a/src/server/schema.ts b/src/server/schema.ts index c2ea13cc..8efd04a8 100644 --- a/src/server/schema.ts +++ b/src/server/schema.ts @@ -24,19 +24,46 @@ export const chat = sqliteTable('chat', { specification_id: integer() .notNull() .references(() => specification.id), - kind: text({ enum: ['interview', 'side_chat'] }).notNull(), - active_turn_id: integer().references((): any => turn.id), created_at: text() .notNull() .default(sql`(datetime('now'))`), }); +export const thread = sqliteTable( + 'thread', + { + id: integer().primaryKey({ autoIncrement: true }), + chat_id: integer() + .notNull() + .references(() => chat.id), + kind: text({ enum: ['interview', 'side', 'reconciliation', 'qa', 'agent_run'] }).notNull(), + target_item_id: integer().references(() => knowledgeItem.id), + context_spec: text(), + kickoff_turn_id: integer().references((): any => turn.id), + invoked_in_turn_id: integer().references((): any => turn.id), + active_turn_id: integer().references((): any => turn.id), + status: text({ enum: ['open', 'closed'] }) + .notNull() + .default('open'), + created_at: text() + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => [ + uniqueIndex('thread_interview_unique') + .on(table.chat_id) + .where(sql`kind = 'interview'`), + ], +); + export const turn = sqliteTable('turn', { id: integer().primaryKey({ autoIncrement: true }), specification_id: integer() .notNull() .references(() => specification.id), - chat_id: integer().references((): any => chat.id), + thread_id: integer() + .notNull() + .references(() => thread.id), parent_turn_id: integer().references((): any => turn.id), phase: text({ enum: ['grounding', 'design', 'requirements', 'criteria'] }).notNull(), turn_kind: text({ enum: ['question', 'kickoff', 'recovery'] }) From 58c8f6fdd28152052b6a3805a76cb9fe37a8c5de Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 17:33:19 +0200 Subject: [PATCH 03/19] =?UTF-8?q?FE-710:=20Inline=20thread=20collapsible?= =?UTF-8?q?=20scaffold=20=E2=80=94=20server=20helpers,=20stream=20interlea?= =?UTF-8?q?ving,=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add createThread/listThreadsForChat helpers and Thread shared type. Include threads in specification state projection with turn counts. Workspace stream projector interleaves thread-collapsible artifacts after the invoking turn; interview threads are excluded. New ThreadCollapsible component renders kind badge, turn count, and expand/collapse toggle. 1263/1263 tests pass. Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d Co-authored-by: Amp --- src/client/components/thread-collapsible.tsx | 65 +++++++++++++++++++ .../_view/-continuous-workspace-controller.ts | 2 + .../$id/_view/-workspace-stream-projector.ts | 59 +++++++++++++++-- .../_view/-workspace-transcript-artifacts.tsx | 11 ++++ .../-workspace-stream-projector.test.ts | 62 ++++++++++++++++++ src/server/core.ts | 25 +++++++ src/server/db.ts | 3 + src/server/db/specification-store.ts | 33 ++++++++++ src/shared/api-types.ts | 18 +++++ 9 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 src/client/components/thread-collapsible.tsx diff --git a/src/client/components/thread-collapsible.tsx b/src/client/components/thread-collapsible.tsx new file mode 100644 index 00000000..616a25e5 --- /dev/null +++ b/src/client/components/thread-collapsible.tsx @@ -0,0 +1,65 @@ +import { ChevronRight } from 'lucide-react'; +import { useState } from 'react'; + +import { cn } from '@/client/lib/utils'; + +const THREAD_KIND_LABELS: Record = { + side: 'Side thread', + reconciliation: 'Reconciliation', + qa: 'QA', + agent_run: 'Agent run', +}; + +const THREAD_KIND_COLORS: Record = { + side: 'bg-blue-500/10 text-blue-600', + reconciliation: 'bg-amber-500/10 text-amber-600', + qa: 'bg-emerald-500/10 text-emerald-600', + agent_run: 'bg-purple-500/10 text-purple-600', +}; + +export interface ThreadCollapsibleProps { + readonly kind: string; + readonly turnCount: number; + readonly status: string; + readonly threadId: number; +} + +export function ThreadCollapsible({ kind, turnCount, status, threadId }: ThreadCollapsibleProps) { + const [isExpanded, setIsExpanded] = useState(false); + const label = THREAD_KIND_LABELS[kind] ?? kind; + const colorClass = THREAD_KIND_COLORS[kind] ?? 'bg-muted text-sub'; + + return ( +
+ + {isExpanded && ( +
+ Thread content will render here. +
+ )} +
+ ); +} diff --git a/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts b/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts index 0ff7ad85..59cd4cb1 100644 --- a/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts +++ b/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts @@ -342,6 +342,7 @@ export function useContinuousWorkspaceController(): ContinuousWorkspaceControlle phaseState, bottomArtifact: enrichedBottomArtifact, structuralArtifactTurnIds: specificationState.structuralArtifactTurnIds, + threads: specificationState.threads, }); result.push({ phase, @@ -357,6 +358,7 @@ export function useContinuousWorkspaceController(): ContinuousWorkspaceControlle phaseState, bottomArtifact: null, structuralArtifactTurnIds: specificationState.structuralArtifactTurnIds, + threads: specificationState.threads, }); result.push({ phase, artifacts: streamArtifacts, phaseTurns: closedPhaseTurns, isActive: false }); } diff --git a/src/client/routes/specification/$id/_view/-workspace-stream-projector.ts b/src/client/routes/specification/$id/_view/-workspace-stream-projector.ts index d23720ac..e5fd5129 100644 --- a/src/client/routes/specification/$id/_view/-workspace-stream-projector.ts +++ b/src/client/routes/specification/$id/_view/-workspace-stream-projector.ts @@ -1,4 +1,4 @@ -import type { WorkflowPhase } from '@/shared/api-types.js'; +import type { Thread, WorkflowPhase } from '@/shared/api-types.js'; import { computeReviewSetChangeSummary, type ReviewSetChangeSummary } from '@/shared/review-diffing.js'; import { getAcceptedClosureReplay, @@ -112,6 +112,10 @@ export type WorkspaceStreamArtifact = | { readonly kind: 'workflow-complete'; readonly artifact: Extract; + } + | { + readonly kind: 'thread-collapsible'; + readonly thread: Thread; }; export interface WorkspaceStreamProjection { @@ -409,6 +413,7 @@ export function specificationWorkspaceStream({ bottomArtifact, controlMarkers = [], structuralArtifactTurnIds: rawStructuralIds, + threads = [], }: { phase: WorkflowPhase; phaseTurns: readonly SpecificationTurn[]; @@ -416,6 +421,7 @@ export function specificationWorkspaceStream({ bottomArtifact: InterviewControllerBottomArtifactState | null; controlMarkers?: readonly WorkspaceStreamMarker[]; structuralArtifactTurnIds?: readonly number[]; + threads?: readonly Thread[]; }): WorkspaceStreamProjection { const structuralArtifactTurnIds = toStructuralArtifactTurnIdSet(rawStructuralIds); const renderedPersistedTurnId = getRenderedPersistedTurnId(bottomArtifact); @@ -425,7 +431,11 @@ export function specificationWorkspaceStream({ renderedPersistedTurnId, structuralArtifactTurnIds, }); - const answeredTurnCount = historyArtifacts.filter( + + // Interleave non-interview thread collapsibles after their invoking turn + const interleavedHistory = interleaveThreadCollapsibles(historyArtifacts, threads); + + const answeredTurnCount = interleavedHistory.filter( (artifact) => artifact.kind === 'answered-turn' || artifact.kind === 'prefaced-question', ).length; const projectedBottomArtifact = projectBottomArtifact(bottomArtifact, answeredTurnCount, phase); @@ -438,17 +448,56 @@ export function specificationWorkspaceStream({ return { streamArtifacts: shouldInsertDivider({ - historyArtifacts, + historyArtifacts: interleavedHistory, controlArtifacts, bottomArtifact: projectedBottomArtifact, }) ? [ ...phaseSectionHeaders, ...phaseMarkers, - ...historyArtifacts, + ...interleavedHistory, { kind: 'divider' as const }, ...tailArtifacts, ] - : [...phaseSectionHeaders, ...phaseMarkers, ...historyArtifacts, ...tailArtifacts], + : [...phaseSectionHeaders, ...phaseMarkers, ...interleavedHistory, ...tailArtifacts], }; } + +function interleaveThreadCollapsibles( + historyArtifacts: WorkspaceStreamArtifact[], + threads: readonly Thread[], +): WorkspaceStreamArtifact[] { + const nonInterviewThreads = threads.filter((t) => t.kind !== 'interview'); + if (nonInterviewThreads.length === 0) return historyArtifacts; + + // Group threads by invoking turn id + const threadsByTurnId = new Map(); + for (const thread of nonInterviewThreads) { + if (thread.invoked_in_turn_id == null) continue; + const existing = threadsByTurnId.get(thread.invoked_in_turn_id); + if (existing) { + existing.push(thread); + } else { + threadsByTurnId.set(thread.invoked_in_turn_id, [thread]); + } + } + + if (threadsByTurnId.size === 0) return historyArtifacts; + + const result: WorkspaceStreamArtifact[] = []; + for (const artifact of historyArtifacts) { + result.push(artifact); + // Insert thread collapsibles after the turn that invoked them + const turnId = 'turn' in artifact ? (artifact.turn as SpecificationTurn)?.id : undefined; + if (turnId != null) { + const attachedThreads = threadsByTurnId.get(turnId); + if (attachedThreads) { + for (const thread of attachedThreads) { + result.push({ kind: 'thread-collapsible', thread }); + } + } + } + } + + return result; +} diff --git a/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx b/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx index 278202e0..45ca43ac 100644 --- a/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx +++ b/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx @@ -17,6 +17,7 @@ import { RevisionCard, } from '@/client/components/question-cards'; import { ReviewPhaseCompletionCard } from '@/client/components/review-set-card'; +import { ThreadCollapsible } from '@/client/components/thread-collapsible'; import type { ActivitySummary } from '@/shared/chat.js'; import { getPhaseRoutePath, getWorkflowPhaseLabel } from '@/shared/phase-descriptors.js'; import { getReviewRevisionNumber, normalizeReviewSetForDisplay } from '@/shared/review-diffing.js'; @@ -540,6 +541,16 @@ export function WorkspaceTranscriptArtifacts({ case 'phase-handoff': case 'workflow-complete': return renderWorkspaceTransitionArtifact({ artifact, specificationId }); + case 'thread-collapsible': + return ( + + ); } })(); diff --git a/src/client/routes/specification/$id/_view/__tests__/-workspace-stream-projector.test.ts b/src/client/routes/specification/$id/_view/__tests__/-workspace-stream-projector.test.ts index d6db8182..b48ff2b4 100644 --- a/src/client/routes/specification/$id/_view/__tests__/-workspace-stream-projector.test.ts +++ b/src/client/routes/specification/$id/_view/__tests__/-workspace-stream-projector.test.ts @@ -698,3 +698,65 @@ describe('specificationWorkspaceStream', () => { ]); }); }); + +describe('thread collapsible interleaving', () => { + it('inserts thread collapsibles after the invoking turn', () => { + const turns = [createTurn({ id: 1 }), createTurn({ id: 2, parent_turn_id: 1 })]; + const threads = [ + { + id: 10, + chat_id: 1, + kind: 'side' as const, + target_item_id: null, + invoked_in_turn_id: 1, + active_turn_id: null, + status: 'open' as const, + turn_count: 3, + created_at: '2026-05-14', + }, + ]; + + const { streamArtifacts } = specificationWorkspaceStream({ + phase: 'grounding', + phaseTurns: turns, + phaseState: createPhaseState(), + bottomArtifact: null, + threads, + }); + + const kinds = streamArtifacts.map((a) => a.kind); + expect(kinds).toContain('thread-collapsible'); + const threadIndex = kinds.indexOf('thread-collapsible'); + const turn1Index = streamArtifacts.findIndex((a) => a.kind === 'answered-turn' && a.turn.id === 1); + expect(threadIndex).toBeGreaterThan(turn1Index); + const turn2Index = streamArtifacts.findIndex((a) => a.kind === 'answered-turn' && a.turn.id === 2); + expect(threadIndex).toBeLessThan(turn2Index); + }); + + it('does not insert collapsibles for interview threads', () => { + const turns = [createTurn({ id: 1 })]; + const threads = [ + { + id: 10, + chat_id: 1, + kind: 'interview' as const, + target_item_id: null, + invoked_in_turn_id: 1, + active_turn_id: null, + status: 'open' as const, + turn_count: 5, + created_at: '2026-05-14', + }, + ]; + + const { streamArtifacts } = specificationWorkspaceStream({ + phase: 'grounding', + phaseTurns: turns, + phaseState: createPhaseState(), + bottomArtifact: null, + threads, + }); + + expect(streamArtifacts.map((a) => a.kind)).not.toContain('thread-collapsible'); + }); +}); diff --git a/src/server/core.ts b/src/server/core.ts index 252aab11..372718af 100644 --- a/src/server/core.ts +++ b/src/server/core.ts @@ -20,6 +20,7 @@ import { getCurrentWorkflowState, getOptionsForTurn, getSpecification, + listThreadsForChat, getTurn, listSpecifications as listPersistedSpecifications, updateTurn, @@ -132,12 +133,36 @@ export function readSpecificationStateProjection(db: DB, specificationId: number const turns = loadActivePathWithOptions(db, specificationId); const workflow = getCurrentWorkflowState(db, specificationId); const structuralArtifactTurnIds = getStructuralArtifactTurnIds(db, specificationId); + + let threads: SpecificationState['threads']; + if (specification.primary_chat_id) { + const rawThreads = listThreadsForChat(db, specification.primary_chat_id); + const threadTurnCounts = (db as any).$client + .prepare('SELECT thread_id, COUNT(*) as count FROM turn GROUP BY thread_id') + .all() as Array<{ thread_id: number; count: number }>; + const turnCountMap = new Map( + threadTurnCounts.map((r: { thread_id: number; count: number }) => [r.thread_id, r.count]), + ); + threads = rawThreads.map((t) => ({ + id: t.id, + chat_id: t.chat_id, + kind: t.kind, + target_item_id: t.target_item_id, + invoked_in_turn_id: t.invoked_in_turn_id, + active_turn_id: t.active_turn_id, + status: t.status, + turn_count: turnCountMap.get(t.id) ?? 0, + created_at: t.created_at, + })); + } + return { specification: toSpecification(specification), workflow, landing: deriveSpecificationLanding({ workflow, turns, structuralArtifactTurnIds }), turns, structuralArtifactTurnIds, + threads, }; } diff --git a/src/server/db.ts b/src/server/db.ts index 312ea1c4..17ac5027 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -132,6 +132,7 @@ export { export type { CreateOptionInput, CreateSpecificationOptions, + CreateThreadInput, CreateTurnInput, Impact, Option, @@ -141,6 +142,8 @@ export type { UpdateTurnInput, } from './db/specification-store.js'; +export { createThread, listThreadsForChat } from './db/specification-store.js'; + export type DB = ReturnType>; export function createDb(path: string = ':memory:'): DB { const sqlite = new Database(path); diff --git a/src/server/db/specification-store.ts b/src/server/db/specification-store.ts index bbc726ed..b831fc1d 100644 --- a/src/server/db/specification-store.ts +++ b/src/server/db/specification-store.ts @@ -282,3 +282,36 @@ export function updateSpecificationMode(db: DB, specificationId: number, mode: S .where(eq(schema.specification.id, specificationId)) .run(); } + +export interface CreateThreadInput { + chatId: number; + kind: 'side' | 'reconciliation' | 'qa' | 'agent_run'; + target_item_id?: number | null; + invoked_in_turn_id?: number | null; + kickoff_turn_id?: number | null; + context_spec?: string | null; +} + +export function createThread(db: DB, input: CreateThreadInput) { + return db + .insert(schema.thread) + .values({ + chat_id: input.chatId, + kind: input.kind, + target_item_id: input.target_item_id ?? null, + invoked_in_turn_id: input.invoked_in_turn_id ?? null, + kickoff_turn_id: input.kickoff_turn_id ?? null, + context_spec: input.context_spec ?? null, + }) + .returning() + .get(); +} + +export function listThreadsForChat(db: DB, chatId: number) { + return db + .select() + .from(schema.thread) + .where(eq(schema.thread.chat_id, chatId)) + .orderBy(schema.thread.created_at) + .all(); +} diff --git a/src/shared/api-types.ts b/src/shared/api-types.ts index bdeba8cb..3e0c2198 100644 --- a/src/shared/api-types.ts +++ b/src/shared/api-types.ts @@ -118,12 +118,28 @@ export const specificationListItemSchema = specificationSchema.extend({ export const specificationListItemsSchema = z.array(specificationListItemSchema); +export const threadKindSchema = z.enum(['interview', 'side', 'reconciliation', 'qa', 'agent_run']); +export const threadStatusSchema = z.enum(['open', 'closed']); + +export const threadSchema = z.object({ + id: z.number().int().positive(), + chat_id: z.number().int().positive(), + kind: threadKindSchema, + target_item_id: z.number().int().positive().nullable(), + invoked_in_turn_id: z.number().int().positive().nullable(), + active_turn_id: z.number().int().positive().nullable(), + status: threadStatusSchema, + turn_count: z.number().int().min(0), + created_at: z.string(), +}); + export const specificationStateSchema = z.object({ specification: specificationSchema, workflow: workflowStateSchema, landing: specificationLandingSchema.nullable().optional(), turns: z.array(specificationStateTurnSchema), structuralArtifactTurnIds: z.array(z.number().int().positive()).optional(), + threads: z.array(threadSchema).optional(), }); const knowledgeItemKindSchema = z.enum(knowledgeKinds); @@ -285,3 +301,5 @@ export type SubmitTurnResponseSelectionRequest = z.infer; export type SubmitTurnResponseRequest = z.infer; export type SubmitTurnResponseResponse = z.infer; +export type Thread = z.infer; +export type ThreadKind = z.infer; From 28e482538918fc8ae6a2553ec6e7d107caebbc04 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 18:13:12 +0200 Subject: [PATCH 04/19] =?UTF-8?q?FE-710:=20Fix=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20thread=20ownership,=20invariant=20enforcement,=20me?= =?UTF-8?q?mo=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore turn-to-chat ownership validation through the thread chain instead of specification_id alone. Throw on missing interview thread instead of silently falling back. Add specificationState.threads to the sections useMemo dependency array. Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d Co-authored-by: Amp --- .../$id/_view/-continuous-workspace-controller.ts | 1 + src/server/capabilities.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts b/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts index 59cd4cb1..db64ec28 100644 --- a/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts +++ b/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts @@ -371,6 +371,7 @@ export function useContinuousWorkspaceController(): ContinuousWorkspaceControlle enrichedBottomArtifact, projectedPhaseTurns, specificationState.structuralArtifactTurnIds, + specificationState.threads, turns, ]); diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 974a9178..6cf452d7 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -240,10 +240,14 @@ function getChatById(db: DB, chatId: number) { .where(and(eq(schema.thread.chat_id, chatId), eq(schema.thread.kind, 'interview'))) .get(); + if (!interviewThread) { + throw new Error(`Chat ${chatId} has no interview thread; substrate invariant violated`); + } + return { ...chatRow, - kind: interviewThread?.kind ?? ('interview' as const), - active_turn_id: interviewThread?.active_turn_id ?? null, + kind: interviewThread.kind, + active_turn_id: interviewThread.active_turn_id, }; } @@ -378,7 +382,12 @@ function submitTurnResponseFromCapability(db: DB, input: TurnSubmitResponseInput if (!turn) { throw new CapabilityDispatchError(`Turn ${input.turnId} not found`, 'handler_failed'); } - if (turn.specification_id !== chat.specification_id) { + const turnThread = db + .select({ chat_id: schema.thread.chat_id }) + .from(schema.thread) + .where(eq(schema.thread.id, turn.thread_id)) + .get(); + if (!turnThread || turnThread.chat_id !== chat.id) { throw new CapabilityDispatchError( `Turn ${input.turnId} does not belong to chat ${input.chatId}`, 'handler_failed', From 41d1742bbd4caac520aec8442231148c1b037a8a Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 18:26:15 +0200 Subject: [PATCH 05/19] FE-710: Consolidate interview-thread lookup, scope turn-count query Extract getInterviewThread and countTurnsPerThread into specification-store; capabilities.ts delegates instead of duplicating the thread query. Turn-count query now scoped to the spec's thread IDs instead of scanning all turns. Removes (db as any) cast from core.ts. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d --- src/server/capabilities.ts | 17 +++------------- src/server/core.ts | 9 ++++----- src/server/db.ts | 7 ++++++- src/server/db/specification-store.ts | 30 ++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 6cf452d7..0dbb2c67 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -1,5 +1,5 @@ import { readUIMessageStream } from 'ai'; -import { and, eq } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { submitTurnResponseRequestSchema } from '@/shared/api-types.js'; @@ -9,7 +9,7 @@ import { getCapabilityContract, type CapabilityId } from './capability-registry. import { applyChatRouteTransition } from './chat-route-transition.js'; import { createNewSpecification, finalizeTurn, getSpecificationState, type TurnWithOptions } from './core.js'; import type { DB, Turn } from './db.js'; -import { getTurn, updateTurn } from './db.js'; +import { getInterviewThread, getTurn, updateTurn } from './db.js'; import { persistFallbackQuestionText, streamInterviewer } from './interview.js'; import { serializeParts, type AssistantPart } from './parts.js'; import * as schema from './schema.js'; @@ -231,18 +231,7 @@ function getChatById(db: DB, chatId: number) { .get(); if (!chatRow) return undefined; - const interviewThread = db - .select({ - kind: schema.thread.kind, - active_turn_id: schema.thread.active_turn_id, - }) - .from(schema.thread) - .where(and(eq(schema.thread.chat_id, chatId), eq(schema.thread.kind, 'interview'))) - .get(); - - if (!interviewThread) { - throw new Error(`Chat ${chatId} has no interview thread; substrate invariant violated`); - } + const interviewThread = getInterviewThread(db, chatId); return { ...chatRow, diff --git a/src/server/core.ts b/src/server/core.ts index 372718af..e0aba958 100644 --- a/src/server/core.ts +++ b/src/server/core.ts @@ -20,6 +20,7 @@ import { getCurrentWorkflowState, getOptionsForTurn, getSpecification, + countTurnsPerThread, listThreadsForChat, getTurn, listSpecifications as listPersistedSpecifications, @@ -137,11 +138,9 @@ export function readSpecificationStateProjection(db: DB, specificationId: number let threads: SpecificationState['threads']; if (specification.primary_chat_id) { const rawThreads = listThreadsForChat(db, specification.primary_chat_id); - const threadTurnCounts = (db as any).$client - .prepare('SELECT thread_id, COUNT(*) as count FROM turn GROUP BY thread_id') - .all() as Array<{ thread_id: number; count: number }>; - const turnCountMap = new Map( - threadTurnCounts.map((r: { thread_id: number; count: number }) => [r.thread_id, r.count]), + const turnCountMap = countTurnsPerThread( + db, + rawThreads.map((t) => t.id), ); threads = rawThreads.map((t) => ({ id: t.id, diff --git a/src/server/db.ts b/src/server/db.ts index 17ac5027..028e7d88 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -142,7 +142,12 @@ export type { UpdateTurnInput, } from './db/specification-store.js'; -export { createThread, listThreadsForChat } from './db/specification-store.js'; +export { + countTurnsPerThread, + createThread, + getInterviewThread, + listThreadsForChat, +} from './db/specification-store.js'; export type DB = ReturnType>; export function createDb(path: string = ':memory:'): DB { diff --git a/src/server/db/specification-store.ts b/src/server/db/specification-store.ts index b831fc1d..95071616 100644 --- a/src/server/db/specification-store.ts +++ b/src/server/db/specification-store.ts @@ -315,3 +315,33 @@ export function listThreadsForChat(db: DB, chatId: number) { .orderBy(schema.thread.created_at) .all(); } + +export function getInterviewThread( + db: DB, + chatId: number, +): { id: number; kind: string; active_turn_id: number | null } { + const row = db + .select({ + id: schema.thread.id, + kind: schema.thread.kind, + active_turn_id: schema.thread.active_turn_id, + }) + .from(schema.thread) + .where(and(eq(schema.thread.chat_id, chatId), eq(schema.thread.kind, 'interview'))) + .get(); + if (!row) { + throw new Error(`Chat ${chatId} has no interview thread; substrate invariant violated`); + } + return row; +} + +export function countTurnsPerThread(db: DB, threadIds: number[]): Map { + if (threadIds.length === 0) return new Map(); + const placeholders = threadIds.map(() => '?').join(','); + const rows = db.$client + .prepare( + `SELECT thread_id, COUNT(*) as count FROM turn WHERE thread_id IN (${placeholders}) GROUP BY thread_id`, + ) + .all(...threadIds) as Array<{ thread_id: number; count: number }>; + return new Map(rows.map((r) => [r.thread_id, r.count])); +} From 65e86a28792573cb93f19fdd26d4c34b9ab9667c Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 18:38:12 +0200 Subject: [PATCH 06/19] FE-710: Add unified chat UX design brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md for the visual / interaction layer pairing with the FE-710 substrate. Locks the V1 decisions for the Ladle prototype: three user modes (Ask/Edit/Reconcile via Shift+Tab) mapped to thread kinds; mention symbols (# items, $ threads, ! annotations/artifacts, @ reserved for code, - omitted); four layout presentations (compact/side-docked/ maximize/full); lucide-react icon family per kind; motion spring expand/collapse; accessibility required, dark mode deferred. Defines ten canonical scenes plus kickoff copy drafts and structural visual recommendations to test in the prototype. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/design/UNIFIED_CHAT_UX.md | 207 +++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/design/UNIFIED_CHAT_UX.md diff --git a/docs/design/UNIFIED_CHAT_UX.md b/docs/design/UNIFIED_CHAT_UX.md new file mode 100644 index 00000000..184913fa --- /dev/null +++ b/docs/design/UNIFIED_CHAT_UX.md @@ -0,0 +1,207 @@ +# 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** cycles 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. | +| **Edit** | `side` | Refine, tighten, or annotate a specific item; cascades resolve in-thread. | +| **Reconcile** | `reconciliation` | Clear open reconciliation needs against a target item. | +| *(no user mode)* | `agent_run` | Assistant-spawned only. Nested 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. + +**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). + +| State | Footprint | Use | +| --- | --- | --- | +| **Compact** | small floating dock, ~360–420 px wide | quick check; minimal surface; suggestions condensed or hidden | +| **Side-docked** | 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 cycles Ask β†’ Edit β†’ Reconcile 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 (Reconcile) + +- **Kickoff:** "**** reconciliations on **''**. **** auto-edit, **** need review." +- **Suggestions:** *Apply auto-edits* Β· *Show only substantive* Β· *Skip for now* + +### 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 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. + +### 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. + +## 14. Locked decisions and remaining prototype questions + +Resolved at brief-lock (reproduced for traceability): + +- **βœ“ Modes via Shift+Tab** β€” Ask / Edit / Reconcile; agent_run = assistant-only; mode persists per thread. +- **βœ“ Symbol mapping** β€” `#` knowledge items (refcode prefix + `kindAccentHex` tint); `$` threads; `!` annotations / untyped artifacts; `@` reserved for code references; `-` omitted. +- **βœ“ Suggestion source** β€” static per mode in V1. +- **βœ“ Layout states** β€” Compact / Side-docked / Maximize / Full. +- **βœ“ Icon family** β€” `lucide-react`; one icon per kind. +- **βœ“ Dark mode** β€” deferred. +- **βœ“ Accessibility** β€” keyboard, ARIA, focus management required. + +Still open for the prototype to settle: + +- **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. From e84fa50722e6b78a28fcfcdc002ebe70e9a73ede Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 18:46:32 +0200 Subject: [PATCH 07/19] =?UTF-8?q?FE-710:=20Refactor=20ThreadCollapsible=20?= =?UTF-8?q?to=20brief=20tone=20=E2=80=94=20icons,=20mode=20labels,=20neutr?= =?UTF-8?q?al=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the inline thread collapsible scaffold with UNIFIED_CHAT_UX.md Β§7 decision 3 (no per-kind background tint; icon + neutral chrome) and Β§8 (per-kind lucide-react icons, single accent hex map parallel to kindAccentHex). Replaces hardcoded Tailwind bg/text utilities with a THREAD_KIND_ACCENT_HEX map; adds PencilLine / RefreshCw / HelpCircle / Sparkles icons per kind; switches labels to the mode register (Edit / Reconcile / Ask / Agent) and drops the UPPERCASE class; swaps the card chrome from bg-tint to white + Figma-aligned shadow stack; adds aria-expanded for keyboard a11y. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/client/components/thread-collapsible.tsx | 70 +++++++++++++------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/src/client/components/thread-collapsible.tsx b/src/client/components/thread-collapsible.tsx index 616a25e5..83753e09 100644 --- a/src/client/components/thread-collapsible.tsx +++ b/src/client/components/thread-collapsible.tsx @@ -1,22 +1,43 @@ -import { ChevronRight } from 'lucide-react'; -import { useState } from 'react'; +import { ChevronRight, HelpCircle, PencilLine, RefreshCw, Sparkles } from 'lucide-react'; +import { useState, type ComponentType, type SVGProps } from 'react'; import { cn } from '@/client/lib/utils'; -const THREAD_KIND_LABELS: Record = { - side: 'Side thread', - reconciliation: 'Reconciliation', - qa: 'QA', - agent_run: 'Agent run', +type NonInterviewThreadKind = 'side' | 'reconciliation' | 'qa' | 'agent_run'; + +// Mode-aligned labels (Ask/Edit/Reconcile per UNIFIED_CHAT_UX.md Β§2). +// `agent_run` has no user mode and falls back to a short substrate label. +const THREAD_KIND_LABEL: Record = { + side: 'Edit', + reconciliation: 'Reconcile', + qa: 'Ask', + agent_run: 'Agent', +}; + +// Per-kind accent hex (parallel to `kindAccentHex` in knowledge-card.tsx). +// Used as a subtle chip tint + matching text color; the surrounding card +// stays neutral chrome per UNIFIED_CHAT_UX.md Β§7 decision 3. +const THREAD_KIND_ACCENT_HEX: Record = { + side: '#2563eb', + reconciliation: '#d97706', + qa: '#16a34a', + agent_run: '#9333ea', }; -const THREAD_KIND_COLORS: Record = { - side: 'bg-blue-500/10 text-blue-600', - reconciliation: 'bg-amber-500/10 text-amber-600', - qa: 'bg-emerald-500/10 text-emerald-600', - agent_run: 'bg-purple-500/10 text-purple-600', +const THREAD_KIND_ICON: Record< + NonInterviewThreadKind, + ComponentType> +> = { + side: PencilLine, + reconciliation: RefreshCw, + qa: HelpCircle, + agent_run: Sparkles, }; +function isKnownThreadKind(kind: string): kind is NonInterviewThreadKind { + return kind === 'side' || kind === 'reconciliation' || kind === 'qa' || kind === 'agent_run'; +} + export interface ThreadCollapsibleProps { readonly kind: string; readonly turnCount: number; @@ -26,40 +47,43 @@ export interface ThreadCollapsibleProps { export function ThreadCollapsible({ kind, turnCount, status, threadId }: ThreadCollapsibleProps) { const [isExpanded, setIsExpanded] = useState(false); - const label = THREAD_KIND_LABELS[kind] ?? kind; - const colorClass = THREAD_KIND_COLORS[kind] ?? 'bg-muted text-sub'; + const known = isKnownThreadKind(kind); + const label = known ? THREAD_KIND_LABEL[kind] : kind; + const accent = known ? THREAD_KIND_ACCENT_HEX[kind] : '#5b5b5b'; + const KindIcon = known ? THREAD_KIND_ICON[kind] : null; return (
- {isExpanded && ( + {isExpanded ? (
Thread content will render here.
- )} + ) : null}
); } From e238992c8f87b1ba9f9029f60d26fb273ac8745a Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 22:58:12 +0200 Subject: [PATCH 08/19] FE-710: ThreadCollapsible inline input + live SSE streaming for side-chat threads Slice 5: side-chat ThreadCollapsible now accepts user input and streams assistant responses in real time via the existing SSE endpoint. - Add specificationId + targetItemId props, resolve itemKind from entities cache - Optimistic local message state with text-delta accumulation - History built from persisted turns + local messages for LLM context - Reconciliation clears local messages when server-persisted turn count grows - Input form with send button, streaming spinner, disabled states - Only rendered for open side-chat threads (kind=side, status!=closed) - SideChatPopover/SideChatHost unchanged (additive, not cutover) Co-authored-by: Amp --- HANDOFF.md | 76 ----- docs/design/UNIFIED_CHAT_UX.md | 17 +- drizzle/0021_turn_phase_nullable.sql | 27 ++ drizzle/meta/_journal.json | 7 + memory/PLAN.md | 2 +- src/client/components/thread-collapsible.tsx | 303 +++++++++++++++++- .../_view/-workspace-transcript-artifacts.tsx | 3 + .../__tests__/-interview-controller.test.tsx | 2 +- .../_view/__tests__/InterviewView.test.tsx | 10 +- src/server/app.ts | 3 +- src/server/capabilities.ts | 6 +- src/server/chat-route-transition.ts | 3 +- src/server/chat-substrate.test.ts | 59 +++- src/server/context.test.ts | 16 +- src/server/core.ts | 60 +++- src/server/db.ts | 5 + src/server/db/specification-store.ts | 84 ++++- src/server/db/workflow-store.ts | 4 +- src/server/fixtures/corpus.ts | 6 +- src/server/observer-prompt.ts | 6 +- src/server/observer.ts | 4 +- src/server/schema.ts | 2 +- src/server/side-chat-route.test.ts | 58 +++- src/server/side-chat-route.ts | 29 ++ src/server/turn-response-transition.ts | 3 +- src/shared/api-types.ts | 10 + 26 files changed, 654 insertions(+), 151 deletions(-) delete mode 100644 HANDOFF.md create mode 100644 drizzle/0021_turn_phase_nullable.sql diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index d4023bda..00000000 --- a/HANDOFF.md +++ /dev/null @@ -1,76 +0,0 @@ -# Handoff - -> Generated by `ln-build` at 2026-05-14. 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 - -Land the **thread substrate** for FE-710 (chat-runtime-threads) and advance to the next slice. - -## Session State - -- **Last completed skill**: `ln-build` β€” substrate-landing slice implemented and verified (1261/1261 tests, full `npm run verify` gate passed). -- **Before that**: prior session ran `ln-design` β†’ `ln-scope` β†’ `ln-spec` β†’ `ln-handoff`; produced the scope card, settled D153/D154/A94, opened PR #138 with the planning baseline. -- **Flow position**: `design β†’ scope β†’ spec β†’ handoff β†’ build (done) β†’ [scope next slice]` - -## What landed - -### Migration 0020 β€” thread substrate - -- `drizzle/0020_thread_substrate.sql` + journal entry -- Created `thread` table: `id`, `chat_id`, `kind` (interview/side/reconciliation/qa/agent_run), `target_item_id`, `context_spec`, `kickoff_turn_id`, `invoked_in_turn_id`, `active_turn_id`, `status`, `created_at` -- Partial unique index `thread_interview_unique` on `(chat_id) WHERE kind = 'interview'` -- Migrated `turn.chat_id` β†’ `turn.thread_id` (NOT NULL) -- Dropped `chat.kind` and `chat.active_turn_id` (chat is now a pure container) -- Data migration: seeded one interview thread per existing chat; mapped turn rows through thread join - -### Schema (`src/server/schema.ts`) - -- Added `thread` table definition with all columns and partial unique index -- Removed `kind` and `active_turn_id` from `chat` -- Changed `turn.chat_id` β†’ `turn.thread_id` (NOT NULL, FK to thread) - -### Specification store (`src/server/db/specification-store.ts`) - -- `insertSpecificationWithInterviewChat` now creates spec + chat + interview thread atomically -- Added `getInterviewThreadIdForSpecification` helper -- `createTurn` routes through interview thread (validates parent in same thread) -- `advanceHead` mirrors `active_turn_id` to interview thread (was chat) - -### Capabilities (`src/server/capabilities.ts`) - -- `getChatById` now joins through thread to source `kind` and `active_turn_id` from interview thread -- `getPrimaryChatFromCapability` delegates to updated `getChatById` -- `submitTurnResponseFromCapability` validates turn ownership through `specification_id` (was `chat_id`) - -### Tests (`src/server/chat-substrate.test.ts`) - -- Full rewrite: 14 tests covering schema assertions, atomic quad creation, thread uniqueness, turn writes, head mirroring, atomicity, and read-path equivalence - -### Blast-radius fixes - -- `src/server/context.test.ts` β€” turn fixtures updated `chat_id: null` β†’ `thread_id: 0` - -## Decisions and assumptions - -| Item | Type | Status | Source | -| --- | --- | --- | --- | -| Eager interview-thread creation in createSpecification | assumption | validated | scope card β†’ code proves it | -| `chat.kind` and `chat.active_turn_id` retire entirely | decision | validated | grep clean; no readers remain | - -## Repo state - -- **Branch**: `ka/fe-710-chat-runtime-threads` -- **PR**: #138 (open, planning baseline only) -- **Dirty files**: all substrate changes uncommitted (ready for commit) -- **Test status**: 1261/1261 passing; `npm run verify` gate clean - -## Next steps - -1. Commit the substrate-landing slice on the current branch. -2. Scope the next slice: in-stream collapsible UI for non-interview thread kinds + `SideChatPopover` retirement cutover. This is still inside FE-710; same branch. -3. Manual walkthrough: `npm run dev`, create a spec, advance through a few grounding turns, reload, confirm continuity. - -## Retirement rule - -- Overwrite or delete this file once the next slice is scoped and the substrate commit is verified. diff --git a/docs/design/UNIFIED_CHAT_UX.md b/docs/design/UNIFIED_CHAT_UX.md index 184913fa..bd01ac1b 100644 --- a/docs/design/UNIFIED_CHAT_UX.md +++ b/docs/design/UNIFIED_CHAT_UX.md @@ -149,7 +149,7 @@ Non-negotiable in this layer (dark mode deferred). - **⌘/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 expand, focus the kickoff card; on collapse, return focus to the invoking turn. +- **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. @@ -168,6 +168,7 @@ New typed parts likely needed (substrate-allowing): `thread.kickoff`, `thread.su - 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 @@ -187,19 +188,9 @@ The prototype lives at `.ladle/` (existing harness, `npm run ladle`). One story 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. -## 14. Locked decisions and remaining prototype questions +## 14. Prototype-settle questions -Resolved at brief-lock (reproduced for traceability): - -- **βœ“ Modes via Shift+Tab** β€” Ask / Edit / Reconcile; agent_run = assistant-only; mode persists per thread. -- **βœ“ Symbol mapping** β€” `#` knowledge items (refcode prefix + `kindAccentHex` tint); `$` threads; `!` annotations / untyped artifacts; `@` reserved for code references; `-` omitted. -- **βœ“ Suggestion source** β€” static per mode in V1. -- **βœ“ Layout states** β€” Compact / Side-docked / Maximize / Full. -- **βœ“ Icon family** β€” `lucide-react`; one icon per kind. -- **βœ“ Dark mode** β€” deferred. -- **βœ“ Accessibility** β€” keyboard, ARIA, focus management required. - -Still open for the prototype to settle: +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)? 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 cefdf311..20cc5ee5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "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 ffebb229..2bd17d0d 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -83,7 +83,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **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:** 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:** substrate-landing slice landed (migration 0020, schema/store/test rewrite). Next slice: in-stream collapsible UI for non-interview thread kinds, `SideChatPopover` retirement cutover. +- **Current execution pointer:** side-chat threads visible in transcript (invoked_in_turn_id anchoring, ThreadCollapsible renders persisted turns, Thread API includes turns for non-interview threads). Next slice: `SideChatPopover` retirement cutover β€” route side-chat interactions through the inline ThreadCollapsible instead of the popover. ### reconciliation-runtime diff --git a/src/client/components/thread-collapsible.tsx b/src/client/components/thread-collapsible.tsx index 83753e09..566de4d2 100644 --- a/src/client/components/thread-collapsible.tsx +++ b/src/client/components/thread-collapsible.tsx @@ -1,7 +1,24 @@ -import { ChevronRight, HelpCircle, PencilLine, RefreshCw, Sparkles } from 'lucide-react'; -import { useState, type ComponentType, type SVGProps } from 'react'; +import { + ChevronRight, + HelpCircle, + Loader2, + PencilLine, + RefreshCw, + SendHorizonal, + Sparkles, +} from 'lucide-react'; +import { useCallback, useEffect, useRef, useState, type ComponentType, type SVGProps } from 'react'; +import { + streamSideChatResponse, + type SideChatPriorTurn, + type SideChatStreamEvent, +} from '@/client/lib/side-chat-stream.js'; import { cn } from '@/client/lib/utils'; +import { queryClient } from '@/client/query-client.js'; +import { specificationQueryKeys } from '@/client/routes/specification/$id/-specification-data.js'; +import type { EntitiesData, ThreadTurn } from '@/shared/api-types.js'; +import type { KnowledgeKind } from '@/shared/knowledge.js'; type NonInterviewThreadKind = 'side' | 'reconciliation' | 'qa' | 'agent_run'; @@ -24,10 +41,7 @@ const THREAD_KIND_ACCENT_HEX: Record = { agent_run: '#9333ea', }; -const THREAD_KIND_ICON: Record< - NonInterviewThreadKind, - ComponentType> -> = { +const THREAD_KIND_ICON: Record>> = { side: PencilLine, reconciliation: RefreshCw, qa: HelpCircle, @@ -38,20 +52,209 @@ function isKnownThreadKind(kind: string): kind is NonInterviewThreadKind { return kind === 'side' || kind === 'reconciliation' || kind === 'qa' || kind === 'agent_run'; } +// --------------------------------------------------------------------------- +// Item-kind resolution from entities cache +// --------------------------------------------------------------------------- + +function resolveItemKindFromCache(specificationId: number, itemId: number): KnowledgeKind | null { + const data = queryClient.getQueryData( + specificationQueryKeys.entitiesProjectWide(String(specificationId)), + ) as EntitiesData | undefined; + if (!data) return null; + + const groups: ReadonlyArray]> = [ + ['goal', data.goals], + ['term', data.terms], + ['context', data.contexts], + ['constraint', data.constraints], + ['decision', data.decisions], + ['assumption', data.assumptions], + ['requirement', data.requirements], + ['criterion', data.criteria], + ]; + for (const [kind, items] of groups) { + if (items.some((item) => item.id === itemId)) { + return kind; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Local message type for optimistic streaming state +// --------------------------------------------------------------------------- + +interface LocalMessage { + readonly localId: string; + readonly role: 'user' | 'assistant'; + readonly text: string; + readonly pending?: boolean; + readonly error?: boolean; +} + +// --------------------------------------------------------------------------- +// History builder β€” mirrors buildHistory in side-chat-host.tsx +// --------------------------------------------------------------------------- + +function buildHistoryFromTurns( + persistedTurns: readonly ThreadTurn[], + localMessages: readonly LocalMessage[], +): SideChatPriorTurn[] { + const history: SideChatPriorTurn[] = []; + + for (const turn of persistedTurns) { + if (turn.text.length === 0) continue; + history.push({ role: turn.role, text: turn.text }); + } + + for (const msg of localMessages) { + if (msg.pending || msg.error || msg.text.length === 0) { + // Drop trailing unpaired user message + if (msg.role === 'assistant' && history.at(-1)?.role === 'user') { + history.pop(); + } + continue; + } + history.push({ role: msg.role, text: msg.text }); + } + + // Always end on assistant (full pair) + if (history.at(-1)?.role === 'user') { + history.pop(); + } + return history; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + export interface ThreadCollapsibleProps { readonly kind: string; readonly turnCount: number; readonly status: string; readonly threadId: number; + readonly turns?: readonly ThreadTurn[]; + /** Required for inline streaming in side-chat threads. */ + readonly specificationId?: number; + /** The target knowledge-item ID for the thread. */ + readonly targetItemId?: number | null; } -export function ThreadCollapsible({ kind, turnCount, status, threadId }: ThreadCollapsibleProps) { +export function ThreadCollapsible({ + kind, + turnCount, + status, + threadId, + turns, + specificationId, + targetItemId, +}: ThreadCollapsibleProps) { const [isExpanded, setIsExpanded] = useState(false); const known = isKnownThreadKind(kind); const label = known ? THREAD_KIND_LABEL[kind] : kind; const accent = known ? THREAD_KIND_ACCENT_HEX[kind] : '#5b5b5b'; const KindIcon = known ? THREAD_KIND_ICON[kind] : null; + // --- Inline streaming state (side-chat only) --- + const canStream = kind === 'side' && specificationId != null && targetItemId != null; + const [localMessages, setLocalMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const streamControllerRef = useRef(null); + const isStreaming = localMessages.some((m) => m.pending); + + // Reconciliation: when persisted turns grow (server confirmed our messages), + // clear local messages since they're now in props.turns. + const prevPersistedCountRef = useRef(turns?.length ?? 0); + useEffect(() => { + const count = turns?.length ?? 0; + if (count > prevPersistedCountRef.current && localMessages.length > 0) { + setLocalMessages([]); + } + prevPersistedCountRef.current = count; + }, [turns?.length, localMessages.length]); + + // Abort stream on unmount + useEffect(() => { + return () => { + streamControllerRef.current?.abort(); + }; + }, []); + + const handleSubmit = useCallback(() => { + const trimmed = inputText.trim(); + if (!trimmed || isStreaming || !canStream) return; + + const itemKind = resolveItemKindFromCache(specificationId!, targetItemId!); + if (!itemKind) return; + + const message = trimmed; + setInputText(''); + + const now = Date.now(); + const userMsg: LocalMessage = { localId: `u-${now}`, role: 'user', text: message }; + const assistantMsg: LocalMessage = { localId: `a-${now}`, role: 'assistant', text: '', pending: true }; + + // Capture history before appending β€” the new user message is sent as + // `message`, not as part of `history`. + const persistedTurns = turns ?? []; + const history = buildHistoryFromTurns(persistedTurns, localMessages); + + setLocalMessages((prev) => [...prev, userMsg, assistantMsg]); + + const controller = new AbortController(); + streamControllerRef.current = controller; + + let buffered = ''; + let failed = false; + + void (async () => { + try { + await streamSideChatResponse( + { + specificationId: specificationId!, + itemKind, + itemId: targetItemId!, + message, + history: history.length > 0 ? history : undefined, + signal: controller.signal, + }, + (event: SideChatStreamEvent) => { + if (controller.signal.aborted) return; + if (event.type === 'text-delta') { + buffered += event.delta; + const snapshot = buffered; + setLocalMessages((prev) => prev.map((m) => (m.pending ? { ...m, text: snapshot } : m))); + } + // Ignore patch-proposal events for this slice + }, + ); + } catch { + failed = !controller.signal.aborted; + } + + if (controller.signal.aborted) return; + if (streamControllerRef.current === controller) { + streamControllerRef.current = null; + } + + setLocalMessages((prev) => { + if (failed) { + return prev.map((m) => + m.pending ? { ...m, text: 'Something went wrong β€” try again.', pending: false, error: true } : m, + ); + } + return prev.flatMap((m) => { + if (!m.pending) return [m]; + return m.text ? [{ ...m, pending: false }] : []; + }); + }); + })(); + }, [inputText, isStreaming, canStream, specificationId, targetItemId, turns, localMessages]); + + // Combined display: persisted turns + optimistic local messages + const displayedTurnCount = (turns?.length ?? 0) + localMessages.filter((m) => !m.pending || m.text).length; + return (
{KindIcon ? - {turnCount} {turnCount === 1 ? 'turn' : 'turns'} + {displayedTurnCount > 0 ? displayedTurnCount : turnCount}{' '} + {(displayedTurnCount > 0 ? displayedTurnCount : turnCount) === 1 ? 'turn' : 'turns'} {status === 'closed' && closed} {isExpanded ? ( -
- Thread content will render here. +
+
+ {/* Persisted turns */} + {turns?.map((turn) => ( +
+ {turn.text} +
+ ))} + + {/* Optimistic local messages (streaming or awaiting reconciliation) */} + {localMessages.map((msg) => { + if (msg.pending && !msg.text) return null; + return ( +
+ {msg.text} + {msg.pending && ( +
+ ); + })} + + {/* Empty state when nothing to show */} + {(turns == null || turns.length === 0) && localMessages.length === 0 && ( + No messages yet. + )} +
+ + {/* Inline input β€” side-chat threads only, when not closed */} + {canStream && status !== 'closed' && ( +
{ + e.preventDefault(); + handleSubmit(); + }} + > + setInputText(e.target.value)} + placeholder="Reply…" + disabled={isStreaming} + className="min-w-0 flex-1 rounded-md border border-rule bg-transparent px-2.5 py-1.5 text-[13px] text-foreground placeholder:text-hint focus:border-blue-400 focus:outline-none disabled:opacity-50" + /> + +
+ )}
) : null}
diff --git a/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx b/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx index 45ca43ac..aabfeaad 100644 --- a/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx +++ b/src/client/routes/specification/$id/_view/-workspace-transcript-artifacts.tsx @@ -549,6 +549,9 @@ export function WorkspaceTranscriptArtifacts({ turnCount={artifact.thread.turn_count} status={artifact.thread.status} threadId={artifact.thread.id} + turns={artifact.thread.turns} + specificationId={Number(specificationId)} + targetItemId={artifact.thread.target_item_id} /> ); } diff --git a/src/client/routes/specification/$id/_view/__tests__/-interview-controller.test.tsx b/src/client/routes/specification/$id/_view/__tests__/-interview-controller.test.tsx index 8b6bac35..4f93efde 100644 --- a/src/client/routes/specification/$id/_view/__tests__/-interview-controller.test.tsx +++ b/src/client/routes/specification/$id/_view/__tests__/-interview-controller.test.tsx @@ -634,7 +634,7 @@ describe('interview controller', () => { currentSpecificationState.workflow.phases.design.status = 'in_progress'; currentSpecificationState.landing = deriveSpecificationLanding(currentSpecificationState); - const rendered = renderController(); + renderController(); expect((await screen.findByTestId('bottom-artifact-kind')).textContent).toBe('phase-handoff'); expect(screen.getByTestId('bottom-artifact').textContent).toBe( diff --git a/src/client/routes/specification/$id/_view/__tests__/InterviewView.test.tsx b/src/client/routes/specification/$id/_view/__tests__/InterviewView.test.tsx index dce16631..bb01fe41 100644 --- a/src/client/routes/specification/$id/_view/__tests__/InterviewView.test.tsx +++ b/src/client/routes/specification/$id/_view/__tests__/InterviewView.test.tsx @@ -738,7 +738,7 @@ afterEach(() => { describe('InterviewView', () => { it('keeps entity-query subscription out of the transcript-owning interview view', async () => { - const rendered = renderWorkspace(); + renderWorkspace(); await screen.findByText('What should we build first?'); @@ -1119,7 +1119,7 @@ describe('InterviewView', () => { }), ); - const rendered = renderWorkspace(); + renderWorkspace(); expect(screen.getByText('Phase 1/4 – Grounding')).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Close Phase' })).toBeNull(); @@ -1136,7 +1136,7 @@ describe('InterviewView', () => { }), ); - const rendered = renderWorkspace(); + renderWorkspace(); expect(screen.queryByRole('button', { name: 'Close Phase' })).toBeNull(); expect(screen.queryByRole('link', { name: /advance to/i })).toBeNull(); @@ -1152,7 +1152,7 @@ describe('InterviewView', () => { }), ); - const rendered = renderWorkspace(); + renderWorkspace(); expect(screen.getByRole('button', { name: 'Close Phase' })).toBeTruthy(); }); @@ -1183,7 +1183,7 @@ describe('InterviewView', () => { }), ); - const rendered = renderWorkspace(); + renderWorkspace(); fireEvent.click(screen.getByRole('button', { name: 'Close Phase' })); diff --git a/src/server/app.ts b/src/server/app.ts index c5d364e1..7ab4e62a 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -45,6 +45,7 @@ import { getTurn, type DB, type EntityProjectionMode, + type InterviewTurn, } from './db.js'; import { handleCreateKnowledgeEdge, @@ -211,7 +212,7 @@ async function ensureObserverCapture({ } const capturePromise = (async () => { - const observerResult = await runObserver(db, turn, specificationId, projectCwd); + const observerResult = await runObserver(db, turn as InterviewTurn, specificationId, projectCwd); appendObserverResultToTurn(db, turn.id, observerResult); })().finally(() => { observerCaptureRegistry.delete(captureKey); diff --git a/src/server/capabilities.ts b/src/server/capabilities.ts index 0dbb2c67..17e3873f 100644 --- a/src/server/capabilities.ts +++ b/src/server/capabilities.ts @@ -8,7 +8,7 @@ import { extractTextFromMessage, structuredQuestionSchema, type BrunchUIMessage import { getCapabilityContract, type CapabilityId } from './capability-registry.js'; import { applyChatRouteTransition } from './chat-route-transition.js'; import { createNewSpecification, finalizeTurn, getSpecificationState, type TurnWithOptions } from './core.js'; -import type { DB, Turn } from './db.js'; +import type { DB, InterviewTurn, Turn } from './db.js'; import { getInterviewThread, getTurn, updateTurn } from './db.js'; import { persistFallbackQuestionText, streamInterviewer } from './interview.js'; import { serializeParts, type AssistantPart } from './parts.js'; @@ -69,7 +69,7 @@ export interface GeneratedAnswerableFrontier { export interface GenerateAnswerableFrontierInput { db: DB; - turn: Turn; + turn: InterviewTurn; activePath: TurnWithOptions[]; userMessage: string; } @@ -432,7 +432,7 @@ async function ensureChatReadyFromCapability( } if (activeState === 'needs_generation') { - const persistedActiveTurn = getTurn(db, activeTurn.id); + const persistedActiveTurn = getTurn(db, activeTurn.id) as InterviewTurn | undefined; if (!persistedActiveTurn) { throw new CapabilityDispatchError(`Turn ${activeTurn.id} not found`, 'handler_failed'); } diff --git a/src/server/chat-route-transition.ts b/src/server/chat-route-transition.ts index 469761ce..b3beb805 100644 --- a/src/server/chat-route-transition.ts +++ b/src/server/chat-route-transition.ts @@ -25,6 +25,7 @@ import { getTurn, supersedePhaseOutcome, type DB, + type InterviewTurn, } from './db.js'; import { getPhaseIntentRuntimeAvailabilityError } from './phase-intent-runtime.js'; @@ -197,7 +198,7 @@ export function applyChatRouteTransition( const currentPhase = getCurrentPhase(db, specificationId); const activeTurnId = getSpecificationRecord(specificationState).active_turn_id; - const activeTurn = activeTurnId ? getTurn(db, activeTurnId) : undefined; + const activeTurn = activeTurnId ? (getTurn(db, activeTurnId) as InterviewTurn | undefined) : undefined; const activeOutcome = activeTurn ? findPhaseOutcomeForTurn(db, specificationId, activeTurn.id) : undefined; if (activeOutcome?.status === 'proposed') { diff --git a/src/server/chat-substrate.test.ts b/src/server/chat-substrate.test.ts index 5e279005..53d2d3c7 100644 --- a/src/server/chat-substrate.test.ts +++ b/src/server/chat-substrate.test.ts @@ -1,6 +1,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { advanceHead, createDb, createSpecification, createTurn, getSpecification, type DB } from './db.js'; +import { + advanceHead, + createDb, + createKnowledgeItem, + createSpecification, + createThread, + createTurn, + createTurnForThread, + findOrCreateSideChatThread, + getSpecification, + type DB, +} from './db.js'; let db: DB; @@ -232,3 +243,49 @@ describe('thread substrate β€” read-path equivalence', () => { expect(row.thread_head).toBe(row.spec_head); }); }); + +describe('side-chat thread lifecycle', () => { + it('findOrCreateSideChatThread creates a side-chat thread for a knowledge item', () => { + const spec = createSpecification(db, 'Test'); + const item = createKnowledgeItem(db, spec.id, 'decision', 'Use SQLite'); + const thread = findOrCreateSideChatThread(db, spec.primary_chat_id!, item.id); + expect(thread.kind).toBe('side'); + expect(thread.target_item_id).toBe(item.id); + expect(thread.chat_id).toBe(spec.primary_chat_id); + expect(thread.status).toBe('open'); + }); + + it('findOrCreateSideChatThread reuses an existing open thread for the same item', () => { + const spec = createSpecification(db, 'Test'); + const item = createKnowledgeItem(db, spec.id, 'decision', 'Use SQLite'); + const first = findOrCreateSideChatThread(db, spec.primary_chat_id!, item.id); + const second = findOrCreateSideChatThread(db, spec.primary_chat_id!, item.id); + expect(second.id).toBe(first.id); + }); + + it('turn.phase is nullable β€” side-chat turns can have null phase', () => { + const spec = createSpecification(db, 'Test'); + const item = createKnowledgeItem(db, spec.id, 'decision', 'Use SQLite'); + const thread = createThread(db, { chatId: spec.primary_chat_id!, kind: 'side', target_item_id: item.id }); + // Insert a turn with null phase directly via raw SQL to prove the schema allows it + db.$client + .prepare(`INSERT INTO turn (specification_id, thread_id, phase, question) VALUES (?, ?, NULL, '')`) + .run(spec.id, thread.id); + const row = db.$client.prepare('SELECT phase FROM turn WHERE thread_id = ?').get(thread.id) as { + phase: string | null; + }; + expect(row.phase).toBeNull(); + }); + + it('createTurnForThread creates a turn with null phase on a side-chat thread', () => { + const spec = createSpecification(db, 'Test'); + const item = createKnowledgeItem(db, spec.id, 'decision', 'Use SQLite'); + const thread = createThread(db, { chatId: spec.primary_chat_id!, kind: 'side', target_item_id: item.id }); + const turn = createTurnForThread(db, spec.id, thread.id, { + user_parts: JSON.stringify([{ type: 'text', text: 'Why SQLite?' }]), + }); + expect(turn.thread_id).toBe(thread.id); + expect(turn.specification_id).toBe(spec.id); + expect(turn.phase).toBeNull(); + }); +}); diff --git a/src/server/context.test.ts b/src/server/context.test.ts index 5541a50d..15e459e7 100644 --- a/src/server/context.test.ts +++ b/src/server/context.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { buildInterviewerContext, buildObserverContext } from './context.js'; import type { TurnWithOptions } from './core.js'; -import type { Turn } from './db.js'; +import type { InterviewTurn } from './db.js'; // --- Interviewer context (I19) --- @@ -538,7 +538,7 @@ describe('buildInterviewerContext', () => { describe('observer-context-projection', () => { it('includes current turn question and answer', () => { - const turn: Turn = { + const turn: InterviewTurn = { id: 5, specification_id: 1, thread_id: 0, @@ -575,7 +575,7 @@ describe('observer-context-projection', () => { }); it('includes brownfield project context when kickoff is grounded in an existing repo', () => { - const turn: Turn = { + const turn: InterviewTurn = { id: 5, specification_id: 1, thread_id: 0, @@ -667,7 +667,7 @@ describe('observer-context-projection', () => { }); it('includes existing entity graph', () => { - const turn: Turn = { + const turn: InterviewTurn = { id: 5, specification_id: 1, thread_id: 0, @@ -707,7 +707,7 @@ describe('observer-context-projection', () => { }); it('omits full conversational history padding', () => { - const turn: Turn = { + const turn: InterviewTurn = { id: 5, specification_id: 1, thread_id: 0, @@ -796,7 +796,7 @@ describe('observer-context-projection', () => { }); it('renders existing knowledge inventory as compact bounded anchors', () => { - const turn: Turn = { + const turn: InterviewTurn = { id: 5, specification_id: 1, thread_id: 0, @@ -842,7 +842,7 @@ describe('observer-context-projection', () => { }); it('includes existing criteria alongside other generic entity sections for later-mode extraction', () => { - const turn: Turn = { + const turn: InterviewTurn = { id: 6, specification_id: 1, thread_id: 0, @@ -887,7 +887,7 @@ describe('observer-context-projection', () => { 'Which exact webhook retry behavior should the first release specify, including how operators inspect failures and decide whether to replay events?'; const longAnswer = 'Operators need to see every failed webhook delivery with the raw provider id, the normalized customer account, the last failure reason, and a one-click replay action after they fix the configuration.'; - const turn: Turn = { + const turn: InterviewTurn = { id: 7, specification_id: 1, thread_id: 0, diff --git a/src/server/core.ts b/src/server/core.ts index e0aba958..f3128d10 100644 --- a/src/server/core.ts +++ b/src/server/core.ts @@ -21,6 +21,7 @@ import { getOptionsForTurn, getSpecification, countTurnsPerThread, + getTurnsForThread, listThreadsForChat, getTurn, listSpecifications as listPersistedSpecifications, @@ -28,10 +29,27 @@ import { type CreateSpecificationOptions, type DB, type Specification as PersistedSpecification, + type InterviewTurn, + type Phase, type Turn, } from './db.js'; import { serializeParts } from './parts.js'; +/** Extract display text from a non-interview thread turn's parts JSON. */ +function extractThreadTurnText(turn: Turn): string { + const raw = turn.user_parts ?? turn.assistant_parts; + if (!raw) return ''; + try { + const parts = JSON.parse(raw) as Array<{ type: string; text?: string }>; + return parts + .filter((p) => p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text!) + .join(''); + } catch { + return raw; + } +} + /** Extract user text from the last UI message. */ export function extractPrompt(messages: BrunchUIMessage[]): string { const lastMessage = messages.at(-1); @@ -42,7 +60,7 @@ export function extractPrompt(messages: BrunchUIMessage[]): string { /** Turn with optional options for richer history formatting. */ export type TurnWithOptions = SpecificationTurn; -type ActivePathTurn = Turn & { +type ActivePathTurn = InterviewTurn & { options: ReturnType; captured_items: NonNullable; }; @@ -77,7 +95,7 @@ export function prepareTurn( specificationId: number, userMessage: string, userParts: BrunchUserPart[], - phase?: Turn['phase'], + phase?: Phase, ) { const specification = getSpecification(db, specificationId); if (!specification) throw new Error(`Specification ${specificationId} not found`); @@ -95,7 +113,7 @@ export function prepareTurn( export function prepareSuccessorTurn( db: DB, specificationId: number, - phase: Turn['phase'], + phase: Phase, parentTurnId: number | null, ) { const specification = getSpecification(db, specificationId); @@ -142,17 +160,31 @@ export function readSpecificationStateProjection(db: DB, specificationId: number db, rawThreads.map((t) => t.id), ); - threads = rawThreads.map((t) => ({ - id: t.id, - chat_id: t.chat_id, - kind: t.kind, - target_item_id: t.target_item_id, - invoked_in_turn_id: t.invoked_in_turn_id, - active_turn_id: t.active_turn_id, - status: t.status, - turn_count: turnCountMap.get(t.id) ?? 0, - created_at: t.created_at, - })); + threads = rawThreads.map((t) => { + const turnCount = turnCountMap.get(t.id) ?? 0; + // Include turns for non-interview threads so the client can render them + const threadTurns = + t.kind !== 'interview' && turnCount > 0 + ? getTurnsForThread(db, t.id).map((turn) => ({ + id: turn.id, + role: turn.user_parts != null ? ('user' as const) : ('assistant' as const), + text: extractThreadTurnText(turn), + created_at: turn.created_at, + })) + : undefined; + return { + id: t.id, + chat_id: t.chat_id, + kind: t.kind, + target_item_id: t.target_item_id, + invoked_in_turn_id: t.invoked_in_turn_id, + active_turn_id: t.active_turn_id, + status: t.status, + turn_count: turnCount, + created_at: t.created_at, + turns: threadTurns, + }; + }); } return { diff --git a/src/server/db.ts b/src/server/db.ts index 028e7d88..cb18b9d8 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -135,6 +135,7 @@ export type { CreateThreadInput, CreateTurnInput, Impact, + InterviewTurn, Option, Phase, Specification, @@ -145,9 +146,13 @@ export type { export { countTurnsPerThread, createThread, + createTurnForThread, + findOrCreateSideChatThread, getInterviewThread, + getTurnsForThread, listThreadsForChat, } from './db/specification-store.js'; +export type { CreateTurnForThreadInput } from './db/specification-store.js'; export type DB = ReturnType>; export function createDb(path: string = ':memory:'): DB { diff --git a/src/server/db/specification-store.ts b/src/server/db/specification-store.ts index 95071616..018a0d7c 100644 --- a/src/server/db/specification-store.ts +++ b/src/server/db/specification-store.ts @@ -11,8 +11,10 @@ type PersistedTurn = InferSelectModel; export type Turn = Omit & { specification_id: number; }; +/** A turn that belongs to an interview thread β€” phase is always set. */ +export type InterviewTurn = Turn & { phase: Phase }; export type Option = InferSelectModel; -export type Phase = Turn['phase']; +export type Phase = NonNullable; export type Impact = NonNullable; export interface CreateTurnInput { @@ -126,7 +128,7 @@ export function getTurn(db: DB, turnId: number): Turn | undefined { return db.select().from(schema.turn).where(eq(schema.turn.id, turnId)).get() as Turn | undefined; } -export function createTurn(db: DB, specificationId: number, input: CreateTurnInput): Turn { +export function createTurn(db: DB, specificationId: number, input: CreateTurnInput): InterviewTurn { const threadId = getInterviewThreadIdForSpecification(db, specificationId); if (input.parent_turn_id != null) { @@ -164,7 +166,7 @@ export function createTurn(db: DB, specificationId: number, input: CreateTurnInp }) .returning() .get(); - return result as Turn; + return result as InterviewTurn; } export interface UpdateTurnInput { @@ -211,7 +213,7 @@ export function createOption(db: DB, turnId: number, input: CreateOptionInput): return result as Option; } -export function getActivePath(db: DB, specificationId: number): Turn[] { +export function getActivePath(db: DB, specificationId: number): InterviewTurn[] { const project = db .select({ active_turn_id: schema.specification.active_turn_id }) .from(schema.specification) @@ -227,7 +229,7 @@ export function getActivePath(db: DB, specificationId: number): Turn[] { ) SELECT * FROM path ORDER BY id ASC `); - return rows as Turn[]; + return rows as InterviewTurn[]; } export function getOptionsForTurn(db: DB, turnId: number): Option[] { @@ -335,6 +337,78 @@ export function getInterviewThread( return row; } +export interface CreateTurnForThreadInput { + parent_turn_id?: number | null; + user_parts?: string | null; + assistant_parts?: string | null; + answer?: string | null; + question?: string; +} + +/** Find an existing open side-chat thread for the given item, or create one. */ +export function findOrCreateSideChatThread( + db: DB, + chatId: number, + targetItemId: number, + invokedInTurnId?: number | null, +) { + const existing = db + .select() + .from(schema.thread) + .where( + and( + eq(schema.thread.chat_id, chatId), + eq(schema.thread.kind, 'side'), + eq(schema.thread.target_item_id, targetItemId), + eq(schema.thread.status, 'open'), + ), + ) + .get(); + if (existing) return existing; + return db + .insert(schema.thread) + .values({ + chat_id: chatId, + kind: 'side', + target_item_id: targetItemId, + invoked_in_turn_id: invokedInTurnId ?? null, + }) + .returning() + .get(); +} + +/** Create a turn directly on a thread (not restricted to interview threads). */ +export function createTurnForThread( + db: DB, + specificationId: number, + threadId: number, + input: CreateTurnForThreadInput, +): Turn { + return db + .insert(schema.turn) + .values({ + specification_id: specificationId, + thread_id: threadId, + parent_turn_id: input.parent_turn_id ?? null, + phase: null, + question: input.question ?? '', + answer: input.answer ?? null, + user_parts: input.user_parts ?? null, + assistant_parts: input.assistant_parts ?? null, + }) + .returning() + .get() as Turn; +} + +export function getTurnsForThread(db: DB, threadId: number): Turn[] { + return db + .select() + .from(schema.turn) + .where(eq(schema.turn.thread_id, threadId)) + .orderBy(schema.turn.created_at) + .all() as Turn[]; +} + export function countTurnsPerThread(db: DB, threadIds: number[]): Map { if (threadIds.length === 0) return new Map(); const placeholders = threadIds.map(() => '?').join(','); diff --git a/src/server/db/workflow-store.ts b/src/server/db/workflow-store.ts index 320ea811..f5ae9801 100644 --- a/src/server/db/workflow-store.ts +++ b/src/server/db/workflow-store.ts @@ -22,7 +22,7 @@ type PersistedTurn = InferSelectModel; type Turn = Omit & { specification_id: number; }; -export type Phase = Turn['phase']; +export type Phase = NonNullable; export type PhaseOutcome = InferSelectModel; export type PhaseOutcomeStatus = PhaseOutcome['status']; export type { WorkflowPhaseStatus, ReadinessBand }; @@ -221,7 +221,7 @@ export function readWorkflowProjectionSnapshot(db: DB, specificationId: number): const activePath = getActivePath(db, specificationId); const activeTurnIds = new Set(activePath.map((turn) => turn.id)); const turns = activePath.map((turn) => ({ - phase: turn.phase, + phase: turn.phase!, question: turn.question, answer: turn.answer, optionCount: getOptionsForTurn(db, turn.id).length, diff --git a/src/server/fixtures/corpus.ts b/src/server/fixtures/corpus.ts index a5b4d03b..b1a9f3bf 100644 --- a/src/server/fixtures/corpus.ts +++ b/src/server/fixtures/corpus.ts @@ -390,7 +390,11 @@ function collectObservedTurnCapture( } export async function observeTurnWithRunObserver(input: ObserveTurnInput): Promise { - const createdIds = await runObserver(input.db, input.turn as import('../db.js').Turn, input.projectId); + const createdIds = await runObserver( + input.db, + input.turn as import('../db.js').InterviewTurn, + input.projectId, + ); return collectObservedTurnCapture(input.db, input.projectId, createdIds); } diff --git a/src/server/observer-prompt.ts b/src/server/observer-prompt.ts index 46cfd3f2..b81a63bd 100644 --- a/src/server/observer-prompt.ts +++ b/src/server/observer-prompt.ts @@ -6,7 +6,7 @@ import { type KnowledgeKind, } from '@/shared/knowledge.js'; -import type { Turn } from './db.js'; +import type { Phase } from './db.js'; import { renderPromptAsset } from './prompt-loader.js'; function formatKindList(kinds: readonly KnowledgeKind[]): string { @@ -15,7 +15,7 @@ function formatKindList(kinds: readonly KnowledgeKind[]): string { return labels.length < 3 ? labels.join(' and ') : `${labels.slice(0, -1).join(', ')}, and ${labels.at(-1)}`; } -function buildObserverPhaseBias(phase: Turn['phase']): string { +function buildObserverPhaseBias(phase: Phase): string { const policy = observerPhaseOntologyPolicies[phase]; const allowedKinds = policy.allowedKinds as readonly KnowledgeKind[]; const correctionKindList = policy.correctionKinds as readonly KnowledgeKind[]; @@ -72,7 +72,7 @@ function buildObserverPhaseBias(phase: Turn['phase']): string { return lines.join(' '); } -export function buildObserverSystemPrompt(phase: Turn['phase']): string { +export function buildObserverSystemPrompt(phase: Phase): string { const phaseBias = buildObserverPhaseBias(phase); const kindSemantics = knowledgeKindRegistry .map((entry, index) => `${index + 1}. **${entry.kind}** β€” ${knowledgeKindSemanticRoles[entry.kind]}.`) diff --git a/src/server/observer.ts b/src/server/observer.ts index a059ad5f..7ba2cb02 100644 --- a/src/server/observer.ts +++ b/src/server/observer.ts @@ -29,7 +29,7 @@ import { getSpecification, type KnowledgeItem, type DB, - type Turn, + type InterviewTurn, } from './db.js'; import { supportsKnowledgeRelationship } from './knowledge-relationship-policy.js'; import { buildObserverSystemPrompt } from './observer-prompt.js'; @@ -181,7 +181,7 @@ function persistObserverRelationships({ */ export async function runObserver( db: DB, - turn: Turn, + turn: InterviewTurn, specificationId: number, workspaceDirectory?: string, ): Promise<{ entityIds: ObserverEntityIds }> { diff --git a/src/server/schema.ts b/src/server/schema.ts index 8efd04a8..3f0e7eb0 100644 --- a/src/server/schema.ts +++ b/src/server/schema.ts @@ -65,7 +65,7 @@ export const turn = sqliteTable('turn', { .notNull() .references(() => thread.id), parent_turn_id: integer().references((): any => turn.id), - phase: text({ enum: ['grounding', 'design', 'requirements', 'criteria'] }).notNull(), + phase: text({ enum: ['grounding', 'design', 'requirements', 'criteria'] }), turn_kind: text({ enum: ['question', 'kickoff', 'recovery'] }) .notNull() .default('question'), diff --git a/src/server/side-chat-route.test.ts b/src/server/side-chat-route.test.ts index 09243c63..738d7b35 100644 --- a/src/server/side-chat-route.test.ts +++ b/src/server/side-chat-route.test.ts @@ -348,7 +348,7 @@ describe('POST /api/specifications/:id/side-chat', () => { expect(res.text).not.toContain('[DONE]'); }); - it('writes zero rows to the turn store across the full request lifecycle (D113 invariant)', async () => { + it('does not affect the interview active path (D113 invariant)', async () => { const specId = await createSpec(); const decision = seedKnowledgeItem(specId, 'decision', 'Use SQLite.'); @@ -363,6 +363,62 @@ describe('POST /api/specifications/:id/side-chat', () => { expect(turnsAfter - turnsBefore).toBe(0); }); + it('creates a side-chat thread and persists user + assistant turns', async () => { + const specId = await createSpec(); + const decision = seedKnowledgeItem(specId, 'decision', 'Use SQLite.'); + + await request(app) + .post(`/api/specifications/${specId}/side-chat`) + .send({ itemKind: 'decision', itemId: decision.id, message: 'Why SQLite?' }) + .expect(200); + + // Verify thread was created + const spec = dbModule.getSpecification(db, specId)!; + const threads = dbModule.listThreadsForChat(db, spec.primary_chat_id!); + const sideThreads = threads.filter((t) => t.kind === 'side'); + expect(sideThreads).toHaveLength(1); + expect(sideThreads[0].target_item_id).toBe(decision.id); + // Anchored at the spec's active_turn_id at creation time + expect(sideThreads[0].invoked_in_turn_id).toBe(spec.active_turn_id); + + // Verify user + assistant turns persisted + const turnCounts = dbModule.countTurnsPerThread(db, [sideThreads[0].id]); + expect(turnCounts.get(sideThreads[0].id)).toBe(2); // 1 user + 1 assistant + }); + + it('reuses the same side-chat thread across multiple messages to the same item', async () => { + mockStreamText.mockReturnValue(makeTextStream(['First ', 'reply.'])); + const specId = await createSpec(); + const decision = seedKnowledgeItem(specId, 'decision', 'Use SQLite.'); + + await request(app) + .post(`/api/specifications/${specId}/side-chat`) + .send({ itemKind: 'decision', itemId: decision.id, message: 'Why SQLite?' }) + .expect(200); + + mockStreamText.mockReturnValue(makeTextStream(['Second ', 'reply.'])); + await request(app) + .post(`/api/specifications/${specId}/side-chat`) + .send({ + itemKind: 'decision', + itemId: decision.id, + message: 'What about backups?', + history: [ + { role: 'user', text: 'Why SQLite?' }, + { role: 'assistant', text: 'Hello from side-chat.' }, + ], + }) + .expect(200); + + const spec = dbModule.getSpecification(db, specId)!; + const threads = dbModule.listThreadsForChat(db, spec.primary_chat_id!); + const sideThreads = threads.filter((t) => t.kind === 'side'); + expect(sideThreads).toHaveLength(1); // reused, not duplicated + + const turnCounts = dbModule.countTurnsPerThread(db, [sideThreads[0].id]); + expect(turnCounts.get(sideThreads[0].id)).toBe(4); // 2 user + 2 assistant + }); + it('does not invoke the observer across the full request lifecycle (D113 invariant)', async () => { const specId = await createSpec(); const decision = seedKnowledgeItem(specId, 'decision', 'Use SQLite.'); diff --git a/src/server/side-chat-route.ts b/src/server/side-chat-route.ts index b24b3743..548a1dd3 100644 --- a/src/server/side-chat-route.ts +++ b/src/server/side-chat-route.ts @@ -7,6 +7,8 @@ import type { EntitiesData, MutationErrorResponse } from '@/shared/api-types.js' import { knowledgeKinds, type KnowledgeKind } from '@/shared/knowledge.js'; import { + createTurnForThread, + findOrCreateSideChatThread, getDownstreamItems, getEntitiesForSpecificationByMode, getSpecification, @@ -141,6 +143,19 @@ export async function handleSideChatRequest(db: DB, req: Request, res: Response) const mode = parsed.data.mode ?? 'explore'; + // --- Thread + user turn persistence --- + const chatId = specification.primary_chat_id; + let sideChatThreadId: number | null = null; + let userTurnId: number | null = null; + if (chatId) { + const thread = findOrCreateSideChatThread(db, chatId, parsed.data.itemId, specification.active_turn_id); + sideChatThreadId = thread.id; + const userTurn = createTurnForThread(db, specificationId, thread.id, { + user_parts: JSON.stringify([{ type: 'text', text: parsed.data.message }]), + }); + userTurnId = userTurn.id; + } + const { system, messages } = buildSideChatPrompt( item, parsed.data.message, @@ -191,6 +206,7 @@ export async function handleSideChatRequest(db: DB, req: Request, res: Response) return classifyEditImpact(downstream.length, inReviewSet); }; let cachedEditImpact: EditImpactTier | null = null; + const assistantTextChunks: string[] = []; try { for await (const part of result.fullStream) { @@ -204,9 +220,22 @@ export async function handleSideChatRequest(db: DB, req: Request, res: Response) return cachedEditImpact; }); if (sseChunk) { + if (sseChunk.type === 'text-delta') { + assistantTextChunks.push(sseChunk.delta); + } res.write(`data: ${JSON.stringify(sseChunk)}\n\n`); } } + // Persist assistant turn after successful stream completion + if (!abortController.signal.aborted && sideChatThreadId != null) { + const assistantText = assistantTextChunks.join(''); + if (assistantText.length > 0) { + createTurnForThread(db, specificationId, sideChatThreadId, { + parent_turn_id: userTurnId, + assistant_parts: JSON.stringify([{ type: 'text', text: assistantText }]), + }); + } + } if (!abortController.signal.aborted) { res.write('data: [DONE]\n\n'); } diff --git a/src/server/turn-response-transition.ts b/src/server/turn-response-transition.ts index c008bea6..a679e5d5 100644 --- a/src/server/turn-response-transition.ts +++ b/src/server/turn-response-transition.ts @@ -20,6 +20,7 @@ import { updateSpecificationMode, updateTurn, type DB, + type InterviewTurn, type Turn, } from './db.js'; import { serializeParts } from './parts.js'; @@ -89,7 +90,7 @@ export function submitTurnResponseTransition({ turnId: number; request: SubmitTurnResponseRequest; }): SubmitTurnResponseResponse | SubmitTurnResponseTransitionError { - const turn = getTurn(db, turnId); + const turn = getTurn(db, turnId) as InterviewTurn | undefined; if (!turn || turn.specification_id !== specificationId) { return { ok: false, kind: 'turn-not-found', message: 'Turn not found' }; } diff --git a/src/shared/api-types.ts b/src/shared/api-types.ts index 3e0c2198..e2427215 100644 --- a/src/shared/api-types.ts +++ b/src/shared/api-types.ts @@ -121,6 +121,15 @@ export const specificationListItemsSchema = z.array(specificationListItemSchema) export const threadKindSchema = z.enum(['interview', 'side', 'reconciliation', 'qa', 'agent_run']); export const threadStatusSchema = z.enum(['open', 'closed']); +export const threadTurnSchema = z.object({ + id: z.number().int().positive(), + role: z.enum(['user', 'assistant']), + text: z.string(), + created_at: z.string(), +}); + +export type ThreadTurn = z.infer; + export const threadSchema = z.object({ id: z.number().int().positive(), chat_id: z.number().int().positive(), @@ -131,6 +140,7 @@ export const threadSchema = z.object({ status: threadStatusSchema, turn_count: z.number().int().min(0), created_at: z.string(), + turns: z.array(threadTurnSchema).optional(), }); export const specificationStateSchema = z.object({ From 81580279afd144db866da04b72f9e83d0c5eafe3 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 23:06:59 +0200 Subject: [PATCH 09/19] FE-710: Route 'Chat' action to inline ThreadCollapsible when thread exists Slice 6: first step of SideChatPopover retirement cutover. When openFor() is called for a knowledge item that already has an open side-chat thread in the spec state cache, the action routes to the inline ThreadCollapsible instead of opening the popover: - SideChatContext gains focusedThreadItemId + clearFocusedThread - openFor checks spec state cache for existing side-chat thread - If found: dismisses any open popover, sets focusedThreadItemId - ThreadCollapsible watches focusedThreadItemId via useSideChat context - On match: auto-expands, scrolls into view, focuses input - If no thread exists: falls back to existing popover behavior Update PLAN.md execution pointer for slice 5 completion. Co-authored-by: Amp --- memory/PLAN.md | 2 +- src/client/components/side-chat-host.tsx | 48 ++++++++++++++++++-- src/client/components/thread-collapsible.tsx | 23 ++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 2bd17d0d..11d288e9 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -83,7 +83,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **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:** 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:** side-chat threads visible in transcript (invoked_in_turn_id anchoring, ThreadCollapsible renders persisted turns, Thread API includes turns for non-interview threads). Next slice: `SideChatPopover` retirement cutover β€” route side-chat interactions through the inline ThreadCollapsible instead of the popover. +- **Current execution pointer:** inline streaming landed (ThreadCollapsible accepts user input, streams assistant responses via SSE, persists turns). Next slice: `SideChatPopover` retirement cutover β€” route side-chat interactions through the inline ThreadCollapsible instead of the popover. ### reconciliation-runtime diff --git a/src/client/components/side-chat-host.tsx b/src/client/components/side-chat-host.tsx index ada09350..df058007 100644 --- a/src/client/components/side-chat-host.tsx +++ b/src/client/components/side-chat-host.tsx @@ -20,7 +20,7 @@ import { } from '@/client/lib/side-chat-stream.js'; import { queryClient } from '@/client/query-client.js'; import { specificationQueryKeys } from '@/client/routes/specification/$id/-specification-data.js'; -import type { EntitiesData } from '@/shared/api-types.js'; +import type { EntitiesData, SpecificationState } from '@/shared/api-types.js'; import type { KnowledgeKind } from '@/shared/knowledge.js'; import { @@ -55,6 +55,9 @@ interface SideChatContextValue { clearSpanHint: () => void; promoteAnnotation: (annotationId: number) => void; setMode: (mode: SideChatMode) => void; + /** When set, the ThreadCollapsible for this item should expand, scroll into view, and focus its input. */ + focusedThreadItemId: number | null; + clearFocusedThread: () => void; } const SideChatContext = createContext(null); @@ -279,6 +282,7 @@ export function SideChatHost({ const [pendingSpanHint, setPendingSpanHint] = useState(null); const [activeCards, setActiveCards] = useState([]); const [layout, setLayout] = useState<'docked' | 'floating'>(readStoredLayout); + const [focusedThreadItemId, setFocusedThreadItemId] = useState(null); useEffect(() => { writeStoredLayout(layout); }, [layout]); @@ -297,8 +301,34 @@ export function SideChatHost({ useEffect(() => abortActiveStream, [abortActiveStream]); + const clearFocusedThread = useCallback(() => { + setFocusedThreadItemId(null); + }, []); + const openFor = useCallback( (item: SideChatPinnableItem) => { + // Check if an inline side-chat thread already exists for this item in the + // spec state cache. If so, route the action to the inline ThreadCollapsible + // instead of opening the popover β€” first step of the popover retirement. + const specState = queryClient.getQueryData(specificationQueryKeys.bundle(String(specificationId))) as + | SpecificationState + | undefined; + const existingThread = specState?.threads?.find( + (t) => t.kind === 'side' && t.target_item_id === item.id && t.status === 'open', + ); + if (existingThread) { + // Dismiss any open popover β€” the inline thread is primary. + if (activeRef.current) { + abortActiveStream(); + activeRef.current = null; + setActiveSideChat(null); + setActiveCards([]); + setPendingSpanHint(null); + } + setFocusedThreadItemId(item.id); + return; + } + const current = activeRef.current; if (current && current.itemKind === item.kind && current.itemId === item.id) { const nextActiveSideChat = { @@ -333,7 +363,7 @@ export function SideChatHost({ activeRef.current = nextActiveSideChat; setActiveSideChat(nextActiveSideChat); }, - [abortActiveStream], + [abortActiveStream, specificationId], ); const openWithSpanHint = useCallback( @@ -843,8 +873,20 @@ export function SideChatHost({ clearSpanHint, promoteAnnotation, setMode, + focusedThreadItemId, + clearFocusedThread, }), - [openFor, openWithSpanHint, activeCardIds, dismissCard, clearSpanHint, promoteAnnotation, setMode], + [ + openFor, + openWithSpanHint, + activeCardIds, + dismissCard, + clearSpanHint, + promoteAnnotation, + setMode, + focusedThreadItemId, + clearFocusedThread, + ], ); const threadItems: readonly SideChatThreadItem[] = activeSideChat diff --git a/src/client/components/thread-collapsible.tsx b/src/client/components/thread-collapsible.tsx index 566de4d2..19c1fdac 100644 --- a/src/client/components/thread-collapsible.tsx +++ b/src/client/components/thread-collapsible.tsx @@ -9,6 +9,7 @@ import { } from 'lucide-react'; import { useCallback, useEffect, useRef, useState, type ComponentType, type SVGProps } from 'react'; +import { useSideChat } from '@/client/components/side-chat-host.js'; import { streamSideChatResponse, type SideChatPriorTurn, @@ -163,6 +164,26 @@ export function ThreadCollapsible({ const streamControllerRef = useRef(null); const isStreaming = localMessages.some((m) => m.pending); + // --- Focus routing from SideChatContext --- + const sideChat = useSideChat(); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!sideChat || sideChat.focusedThreadItemId == null || targetItemId == null) return; + if (sideChat.focusedThreadItemId !== targetItemId) return; + + // This ThreadCollapsible is the target β€” expand, scroll, and focus. + setIsExpanded(true); + sideChat.clearFocusedThread(); + + // Defer scroll + focus to next frame so the expanded content is rendered. + requestAnimationFrame(() => { + containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + inputRef.current?.focus(); + }); + }, [sideChat, targetItemId]); + // Reconciliation: when persisted turns grow (server confirmed our messages), // clear local messages since they're now in props.turns. const prevPersistedCountRef = useRef(turns?.length ?? 0); @@ -257,6 +278,7 @@ export function ThreadCollapsible({ return (
@@ -342,6 +364,7 @@ export function ThreadCollapsible({ }} > setInputText(e.target.value)} From ef863f6bc467a77133aac2dba1bb50a6670e8f44 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 23:14:49 +0200 Subject: [PATCH 10/19] FE-710: Add POST /api/specifications/:id/threads endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 7 infrastructure: server endpoint for eager thread creation. - POST /api/specifications/:id/threads creates (or finds) a side-chat thread for the given targetItemId, returning { ok: true, threadId } - Uses existing findOrCreateSideChatThread (idempotent) - Preserves popover fallback for new chats β€” the inline ThreadCollapsible doesn't support edit mode, patch staging, or annotations yet, so the popover still owns first-chat creation while inline handles continuation Co-authored-by: Amp --- src/client/components/side-chat-host.tsx | 13 ++++---- src/server/app.ts | 39 +++++++++++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/client/components/side-chat-host.tsx b/src/client/components/side-chat-host.tsx index df058007..18125804 100644 --- a/src/client/components/side-chat-host.tsx +++ b/src/client/components/side-chat-host.tsx @@ -308,8 +308,8 @@ export function SideChatHost({ const openFor = useCallback( (item: SideChatPinnableItem) => { // Check if an inline side-chat thread already exists for this item in the - // spec state cache. If so, route the action to the inline ThreadCollapsible - // instead of opening the popover β€” first step of the popover retirement. + // spec state cache. If so, route to the inline ThreadCollapsible instead + // of opening the popover (progressive popover retirement). const specState = queryClient.getQueryData(specificationQueryKeys.bundle(String(specificationId))) as | SpecificationState | undefined; @@ -329,6 +329,11 @@ export function SideChatHost({ return; } + // No thread yet β€” fall back to the popover, which still owns edit mode, + // patch staging, and annotation features the inline thread doesn't have. + // The popover's first message will create the thread server-side; next + // time the user clicks "Chat" on this item, the thread exists and we + // route inline above. const current = activeRef.current; if (current && current.itemKind === item.kind && current.itemId === item.id) { const nextActiveSideChat = { @@ -344,8 +349,6 @@ export function SideChatHost({ abortActiveStream(); sessionCounterRef.current += 1; - // Single-pin scope: switching to a different (kind, id) clears cards/hint so stale - // state doesn't leak across items. Reopening the same item focuses the existing session. setActiveCards([]); setPendingSpanHint(null); const nextActiveSideChat: ActiveSideChat = { @@ -356,8 +359,6 @@ export function SideChatHost({ messages: [], messageTimestamps: [], annotateMode: false, - // Card 4 follow-up: adopt the persisted mode so reopening the side-chat - // (or pinning a new item) inherits the user's last toggle state. mode: readStoredMode(), }; activeRef.current = nextActiveSideChat; diff --git a/src/server/app.ts b/src/server/app.ts index 7ab4e62a..c2e45005 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import { createUIMessageStream, pipeUIMessageStreamToResponse, validateUIMessages } from 'ai'; import express from 'express'; import type { ErrorRequestHandler, Express, Request, RequestHandler, Response } from 'express'; +import * as z from 'zod/v4'; import { submitPhaseIntentRequestSchema, submitTurnResponseRequestSchema } from '@/shared/api-types.js'; import type { @@ -39,10 +40,12 @@ import { } from './core.js'; import { createDb, + findOrCreateSideChatThread, findPhaseOutcomeForTurn, - updateTurn, getEntitiesForSpecificationByMode, + getSpecification, getTurn, + updateTurn, type DB, type EntityProjectionMode, type InterviewTurn, @@ -243,6 +246,7 @@ export function createApp(dbPathOrOptions?: string | AppOptions): AppServices { const specificationExportPaths = ['/api/specifications/:id/export'] as const; const specificationChatPaths = ['/api/specifications/:id/chat'] as const; const specificationSideChatPaths = ['/api/specifications/:id/side-chat'] as const; + const specificationThreadsPaths = ['/api/specifications/:id/threads'] as const; const specificationAnnotationsPaths = ['/api/specifications/:id/annotations'] as const; const annotationResourcePaths = ['/api/annotations/:annotationId'] as const; const specificationKnowledgeItemPaths = ['/api/specifications/:id/knowledge-items/:itemId'] as const; @@ -626,6 +630,39 @@ export function createApp(dbPathOrOptions?: string | AppOptions): AppServices { await handleSideChatRequest(db, req, res); }); + registerPost(specificationThreadsPaths, (req: Request, res: Response) => { + const specificationId = Number(req.params.id); + if (!Number.isFinite(specificationId)) { + res.status(400).json({ error: 'Invalid specification ID' } satisfies MutationErrorResponse); + return; + } + + const parsed = z.object({ targetItemId: z.number().int().positive() }).safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid payload' } satisfies MutationErrorResponse); + return; + } + + const specification = getSpecification(db, specificationId); + if (!specification) { + res.status(404).json({ error: 'Specification not found' } satisfies MutationErrorResponse); + return; + } + + if (specification.primary_chat_id == null) { + res.status(404).json({ error: 'Specification has no primary chat' } satisfies MutationErrorResponse); + return; + } + + const thread = findOrCreateSideChatThread( + db, + specification.primary_chat_id, + parsed.data.targetItemId, + specification.active_turn_id, + ); + res.json({ ok: true, threadId: thread.id }); + }); + registerPost(specificationAnnotationsPaths, (req: Request, res: Response) => { handleCreateAnnotation(db, req, res); }); From 3b1164aa807e1fce7908cfbb920bedc09eb43f72 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 23:15:04 +0200 Subject: [PATCH 11/19] FE-710: Update PLAN execution pointer after slices 5-7 Co-authored-by: Amp --- memory/PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index 11d288e9..cfde9866 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -83,7 +83,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **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:** 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:** inline streaming landed (ThreadCollapsible accepts user input, streams assistant responses via SSE, persists turns). Next slice: `SideChatPopover` retirement cutover β€” route side-chat interactions through the inline ThreadCollapsible instead of the popover. +- **Current execution pointer:** inline streaming + inline-first routing landed (ThreadCollapsible streams responses; "Chat" action routes to inline thread when one exists, popover fallback for first chat). Thread creation endpoint ready for eager creation. Next: in-thread edit mode + patch staging to unblock full popover retirement, or structural schema changes (`turn.thread_id` replaces `turn.chat_id`). ### reconciliation-runtime From 44f9a6cf38b6b9a70d3252e261619a9fd01c7121 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 23:39:04 +0200 Subject: [PATCH 12/19] FE-710: ThreadCollapsible edit mode + patch staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 8: in-thread mutation state for the inline ThreadCollapsible. - Mode toggle (explore/edit) with shared localStorage persistence - Sends mode in SSE request; server registers propose_edit tool in edit mode - Handles all patch-proposal event types in stream callback: propose_edit β†’ patchList.stage(edit), propose_edge β†’ stage(edge), propose_drill_down β†’ stage(drill-down) - Staged patches indicator with count + Apply button - Richer entity resolver: resolveTargetItemFromCache returns kind, referenceCode, and content for both patch anchoring and diff support - resolveEdgeTargetFromCache for propose_edge target resolution - Edit mode placeholder text changes to 'Propose an edit…' - Toggle only visible when PatchList is available (inside PatchListProvider) Co-authored-by: Amp --- src/client/components/thread-collapsible.tsx | 279 ++++++++++++++++--- 1 file changed, 240 insertions(+), 39 deletions(-) diff --git a/src/client/components/thread-collapsible.tsx b/src/client/components/thread-collapsible.tsx index 19c1fdac..1019494a 100644 --- a/src/client/components/thread-collapsible.tsx +++ b/src/client/components/thread-collapsible.tsx @@ -1,4 +1,5 @@ import { + Check, ChevronRight, HelpCircle, Loader2, @@ -7,7 +8,7 @@ import { SendHorizonal, Sparkles, } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState, type ComponentType, type SVGProps } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type SVGProps } from 'react'; import { useSideChat } from '@/client/components/side-chat-host.js'; import { @@ -21,6 +22,8 @@ import { specificationQueryKeys } from '@/client/routes/specification/$id/-speci import type { EntitiesData, ThreadTurn } from '@/shared/api-types.js'; import type { KnowledgeKind } from '@/shared/knowledge.js'; +import { usePatchList, usePatchListState, useStagedPatches } from './patch-list-host.js'; + type NonInterviewThreadKind = 'side' | 'reconciliation' | 'qa' | 'agent_run'; // Mode-aligned labels (Ask/Edit/Reconcile per UNIFIED_CHAT_UX.md Β§2). @@ -54,16 +57,24 @@ function isKnownThreadKind(kind: string): kind is NonInterviewThreadKind { } // --------------------------------------------------------------------------- -// Item-kind resolution from entities cache +// Target-item resolution from entities cache // --------------------------------------------------------------------------- -function resolveItemKindFromCache(specificationId: number, itemId: number): KnowledgeKind | null { +interface ResolvedTargetItem { + kind: KnowledgeKind; + referenceCode: string; + content: string; +} + +function resolveTargetItemFromCache(specificationId: number, itemId: number): ResolvedTargetItem | null { const data = queryClient.getQueryData( specificationQueryKeys.entitiesProjectWide(String(specificationId)), ) as EntitiesData | undefined; if (!data) return null; - const groups: ReadonlyArray]> = [ + const groups: ReadonlyArray< + readonly [KnowledgeKind, ReadonlyArray<{ id: number; referenceCode?: string | null; content: string }>] + > = [ ['goal', data.goals], ['term', data.terms], ['context', data.contexts], @@ -74,8 +85,42 @@ function resolveItemKindFromCache(specificationId: number, itemId: number): Know ['criterion', data.criteria], ]; for (const [kind, items] of groups) { - if (items.some((item) => item.id === itemId)) { - return kind; + for (const item of items) { + if (item.id === itemId && item.referenceCode) { + return { kind, referenceCode: item.referenceCode, content: item.content }; + } + } + } + return null; +} + +// Edge-target resolution (for propose_edge) β€” same as side-chat-host.tsx. +function resolveEdgeTargetFromCache( + specificationId: number, + referenceCode: string, +): { kind: KnowledgeKind; itemId: number; referenceCode: string } | null { + const data = queryClient.getQueryData( + specificationQueryKeys.entitiesProjectWide(String(specificationId)), + ) as EntitiesData | undefined; + if (!data) return null; + + const groups: ReadonlyArray< + readonly [KnowledgeKind, ReadonlyArray<{ id: number; referenceCode?: string | null }>] + > = [ + ['goal', data.goals], + ['term', data.terms], + ['context', data.contexts], + ['constraint', data.constraints], + ['decision', data.decisions], + ['assumption', data.assumptions], + ['requirement', data.requirements], + ['criterion', data.criteria], + ]; + for (const [kind, items] of groups) { + for (const item of items) { + if (item.referenceCode === referenceCode) { + return { kind, itemId: item.id, referenceCode }; + } } } return null; @@ -126,6 +171,46 @@ function buildHistoryFromTurns( return history; } +// --------------------------------------------------------------------------- +// Mode persistence β€” shares key with SideChatPopover for consistency +// --------------------------------------------------------------------------- + +const SIDE_CHAT_MODE_STORAGE_KEY = 'brunch.side-chat.mode'; + +type SideChatMode = 'explore' | 'edit'; + +function readStoredMode(): SideChatMode { + if (typeof window === 'undefined') return 'explore'; + try { + return window.localStorage.getItem(SIDE_CHAT_MODE_STORAGE_KEY) === 'edit' ? 'edit' : 'explore'; + } catch { + return 'explore'; + } +} + +function writeStoredMode(mode: SideChatMode): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(SIDE_CHAT_MODE_STORAGE_KEY, mode); + } catch { + // Storage may be unavailable; ignore. + } +} + +// --------------------------------------------------------------------------- +// Edit-content summary β€” mirrors summarizeEditContent in side-chat-host.tsx +// --------------------------------------------------------------------------- + +const EDIT_SUMMARY_PREVIEW_LIMIT = 60; + +function summarizeEditContent(newContent: string): string { + const trimmed = newContent.trim(); + if (trimmed.length <= EDIT_SUMMARY_PREVIEW_LIMIT) { + return `Edit: ${trimmed}`; + } + return `Edit: ${trimmed.slice(0, EDIT_SUMMARY_PREVIEW_LIMIT - 1)}…`; +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -161,9 +246,19 @@ export function ThreadCollapsible({ const canStream = kind === 'side' && specificationId != null && targetItemId != null; const [localMessages, setLocalMessages] = useState([]); const [inputText, setInputText] = useState(''); + const [mode, setMode] = useState(readStoredMode); const streamControllerRef = useRef(null); const isStreaming = localMessages.some((m) => m.pending); + // --- Patch list integration --- + const patchList = usePatchList(); + const patchListState = usePatchListState(); + const resolvedItem = canStream ? resolveTargetItemFromCache(specificationId!, targetItemId!) : null; + const activeStagedPatches = useStagedPatches( + resolvedItem ? { anchor: { kind: resolvedItem.kind, itemId: targetItemId! } } : undefined, + ); + const stagedPatchIds = useMemo(() => activeStagedPatches.map((p) => p.id), [activeStagedPatches]); + // --- Focus routing from SideChatContext --- const sideChat = useSideChat(); const containerRef = useRef(null); @@ -202,12 +297,23 @@ export function ThreadCollapsible({ }; }, []); + const toggleMode = useCallback(() => { + const next: SideChatMode = mode === 'explore' ? 'edit' : 'explore'; + setMode(next); + writeStoredMode(next); + }, [mode]); + + const handleApply = useCallback(() => { + if (!patchList || stagedPatchIds.length === 0) return; + void patchList.apply(stagedPatchIds); + }, [patchList, stagedPatchIds]); + const handleSubmit = useCallback(() => { const trimmed = inputText.trim(); if (!trimmed || isStreaming || !canStream) return; - const itemKind = resolveItemKindFromCache(specificationId!, targetItemId!); - if (!itemKind) return; + const resolved = resolveTargetItemFromCache(specificationId!, targetItemId!); + if (!resolved) return; const message = trimmed; setInputText(''); @@ -234,11 +340,12 @@ export function ThreadCollapsible({ await streamSideChatResponse( { specificationId: specificationId!, - itemKind, + itemKind: resolved.kind, itemId: targetItemId!, message, history: history.length > 0 ? history : undefined, signal: controller.signal, + ...(mode !== 'explore' ? { mode } : {}), }, (event: SideChatStreamEvent) => { if (controller.signal.aborted) return; @@ -246,8 +353,40 @@ export function ThreadCollapsible({ buffered += event.delta; const snapshot = buffered; setLocalMessages((prev) => prev.map((m) => (m.pending ? { ...m, text: snapshot } : m))); + } else if (event.type === 'patch-proposal' && patchList) { + if (event.toolName === 'propose_edit') { + patchList.stage({ + kind: 'edit', + anchor: { kind: resolved.kind, itemId: targetItemId! }, + anchorReferenceCode: resolved.referenceCode, + summary: summarizeEditContent(event.input.newContent), + currentContent: resolved.content, + newContent: event.input.newContent, + ...(event.input.newRationale ? { newRationale: event.input.newRationale } : {}), + ...(event.impact !== undefined ? { impact: event.impact } : {}), + }); + } else if (event.toolName === 'propose_edge') { + const target = resolveEdgeTargetFromCache(specificationId!, event.input.targetReferenceCode); + if (target) { + patchList.stage({ + kind: 'edge', + anchor: { kind: resolved.kind, itemId: targetItemId! }, + anchorReferenceCode: resolved.referenceCode, + targetAnchor: { kind: target.kind, itemId: target.itemId }, + relation: event.input.relation, + summary: `Edge: ${resolved.referenceCode} ${event.input.relation.replaceAll('_', ' ')} ${target.referenceCode}`, + }); + } + } else if (event.toolName === 'propose_drill_down') { + patchList.stage({ + kind: 'drill-down', + anchor: { kind: resolved.kind, itemId: targetItemId! }, + anchorReferenceCode: resolved.referenceCode, + summary: `Drill-down: ${event.input.focusArea}`, + focusArea: event.input.focusArea, + }); + } } - // Ignore patch-proposal events for this slice }, ); } catch { @@ -271,7 +410,17 @@ export function ThreadCollapsible({ }); }); })(); - }, [inputText, isStreaming, canStream, specificationId, targetItemId, turns, localMessages]); + }, [ + inputText, + isStreaming, + canStream, + specificationId, + targetItemId, + turns, + localMessages, + mode, + patchList, + ]); // Combined display: persisted turns + optimistic local messages const displayedTurnCount = (turns?.length ?? 0) + localMessages.filter((m) => !m.pending || m.text).length; @@ -353,38 +502,90 @@ export function ThreadCollapsible({ )}
- {/* Inline input β€” side-chat threads only, when not closed */} - {canStream && status !== 'closed' && ( -
{ - e.preventDefault(); - handleSubmit(); - }} + {/* Staged patches indicator */} + {canStream && activeStagedPatches.length > 0 && ( +
- setInputText(e.target.value)} - placeholder="Reply…" - disabled={isStreaming} - className="min-w-0 flex-1 rounded-md border border-rule bg-transparent px-2.5 py-1.5 text-[13px] text-foreground placeholder:text-hint focus:border-blue-400 focus:outline-none disabled:opacity-50" - /> + + {activeStagedPatches.length} {activeStagedPatches.length === 1 ? 'edit' : 'edits'} staged + - +
+ )} + + {/* Inline input β€” side-chat threads only, when not closed */} + {canStream && status !== 'closed' && ( + <> +
{ + e.preventDefault(); + handleSubmit(); + }} + > + setInputText(e.target.value)} + placeholder={mode === 'edit' ? 'Propose an edit…' : 'Reply…'} + disabled={isStreaming} + className="min-w-0 flex-1 rounded-md border border-rule bg-transparent px-2.5 py-1.5 text-[13px] text-foreground placeholder:text-hint focus:border-blue-400 focus:outline-none disabled:opacity-50" + /> + +
+ + {/* Edit-mode toggle */} + {patchList && ( +
+ + + Edit mode + + +
+ )} + )}
) : null} From 4dfe732b53927cfb8ca3656a8647383660aa3c7d Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 23:44:47 +0200 Subject: [PATCH 13/19] =?UTF-8?q?FE-710:=20Eager=20thread=20creation=20cut?= =?UTF-8?q?over=20=E2=80=94=20openFor=20always=20routes=20inline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 9: full popover retirement for openFor. Every 'Chat' action now creates a thread eagerly (via POST /api/specifications/:id/threads) and focuses the inline ThreadCollapsible. The SideChatPopover never opens from openFor. - openFor: dismiss any open popover, check for existing thread in cache, create eagerly if absent, invalidate spec state, focus inline - Remove dead sessionCounterRef and readStoredMode - Skip 46 popover-dependent tests with retirement note (describe.skip) β€” these test the retired popover-based flow; delete when popover rendering is removed from SideChatHost Popover code remains in SideChatHost as retirement debt. The inline ThreadCollapsible now handles explore + edit mode with patch staging. Co-authored-by: Amp --- docs/design/UNIFIED_CHAT_UX.md | 13 ++- .../__tests__/side-chat-host.test.tsx | 22 +++-- src/client/components/side-chat-host.tsx | 87 +++++++------------ .../__tests__/structured-list-view.test.tsx | 5 +- 4 files changed, 57 insertions(+), 70 deletions(-) diff --git a/docs/design/UNIFIED_CHAT_UX.md b/docs/design/UNIFIED_CHAT_UX.md index bd01ac1b..0b4ba0de 100644 --- a/docs/design/UNIFIED_CHAT_UX.md +++ b/docs/design/UNIFIED_CHAT_UX.md @@ -40,12 +40,12 @@ Autocomplete pops on `#`, `$`, or `!` press via Radix `Combobox` / `cmdk` (alrea ## 4. Layout presentations -Four states, user-toggleable from a header control on the chat. State persists per workspace (localStorage). +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** | right rail, ~50% width | Notion-style; two-task β€” spec on left, chat on right | +| **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 | @@ -188,6 +188,15 @@ The prototype lives at `.ladle/` (existing harness, `npm run ladle`). One story 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. diff --git a/src/client/components/__tests__/side-chat-host.test.tsx b/src/client/components/__tests__/side-chat-host.test.tsx index 86a80b7d..554e9df9 100644 --- a/src/client/components/__tests__/side-chat-host.test.tsx +++ b/src/client/components/__tests__/side-chat-host.test.tsx @@ -134,7 +134,11 @@ afterEach(() => { consoleErrorSpy.mockRestore(); }); -describe('SideChatHost edit-mode flow (V2)', () => { +// All SideChatHost popover tests are skipped: openFor() now routes all +// side-chat interactions to inline ThreadCollapsibles via eager thread +// creation (FE-710 slice 9). The popover code and these tests are retirement +// debt β€” delete when the popover rendering is removed from SideChatHost. +describe.skip('SideChatHost edit-mode flow (V2)', () => { function OpenInEditModeButton({ item }: { item: SideChatPinnableItem }) { const sideChat = useSideChat(); return ( @@ -463,7 +467,7 @@ describe('SideChatHost edit-mode flow (V2)', () => { }); }); -describe('SideChatHost annotate flow', () => { +describe.skip('SideChatHost annotate flow', () => { it('clicking Annotate switches the popover into composer mode', () => { const { appliers } = makeAppliers(); render( @@ -832,7 +836,7 @@ describe('SideChatHost annotate flow', () => { }); }); -describe('SideChatHost active cards', () => { +describe.skip('SideChatHost active cards', () => { it('exposes activeCardIds and dismissCard via context; pushes ids on apply', async () => { const { appliers, annotateMock } = makeAppliers(); annotateMock.mockImplementation(() => @@ -947,7 +951,7 @@ describe('SideChatHost active cards', () => { }); }); -describe('SideChatHost thread interleaving', () => { +describe.skip('SideChatHost thread interleaving', () => { it('renders an active card chronologically interleaved with messages', async () => { const { appliers, annotateMock } = makeAppliers(); annotateMock.mockImplementation(() => @@ -977,7 +981,7 @@ describe('SideChatHost thread interleaving', () => { }); }); -describe('SideChatHost dismiss/reopen state isolation', () => { +describe.skip('SideChatHost dismiss/reopen state isolation', () => { it('keeps the existing thread when reopening the already-active item', async () => { const { appliers } = makeAppliers(); @@ -1141,7 +1145,7 @@ describe('SideChatHost dismiss/reopen state isolation', () => { }); }); -describe('SideChatHost span hints', () => { +describe.skip('SideChatHost span hints', () => { it('forwards openWithSpanHint and includes spanHint in the next stream request', async () => { const streamMock = vi.mocked(streamSideChatResponse); streamMock.mockClear(); @@ -1219,7 +1223,7 @@ describe('SideChatHost span hints', () => { }); }); -describe('SideChatHost active annotations payload', () => { +describe.skip('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, @@ -1459,7 +1463,7 @@ describe('SideChatHost active annotations payload', () => { }); }); -describe('SideChatHost span-hint chip', () => { +describe.skip('SideChatHost span-hint chip', () => { it('renders a span-hint chip in the panel when openWithSpanHint is called', async () => { const { appliers } = makeAppliers(); @@ -1528,7 +1532,7 @@ describe('SideChatHost span-hint chip', () => { }); }); -describe('SideChatHost promote annotation', () => { +describe.skip('SideChatHost promote annotation', () => { it('promoteAnnotation pushes the annotation onto activeCardIds', async () => { const inertAnnotation: CreatedAnnotation = { id: 555, diff --git a/src/client/components/side-chat-host.tsx b/src/client/components/side-chat-host.tsx index 18125804..8e685da3 100644 --- a/src/client/components/side-chat-host.tsx +++ b/src/client/components/side-chat-host.tsx @@ -226,18 +226,6 @@ function writeStoredLayout(layout: 'docked' | 'floating'): void { } } -// Card 4 follow-up: Edit-mode toggle persists across sessions and pinned items -// so the user's last preference survives reload. A new pinned item adopts the -// stored mode rather than always falling back to 'explore'. -function readStoredMode(): SideChatMode { - if (typeof window === 'undefined') return 'explore'; - try { - return window.localStorage.getItem(SIDE_CHAT_MODE_STORAGE_KEY) === 'edit' ? 'edit' : 'explore'; - } catch { - return 'explore'; - } -} - function writeStoredMode(mode: SideChatMode): void { if (typeof window === 'undefined') return; try { @@ -287,7 +275,6 @@ export function SideChatHost({ writeStoredLayout(layout); }, [layout]); const activeRef = useRef(null); - const sessionCounterRef = useRef(0); const streamControllerRef = useRef(null); useEffect(() => { @@ -307,9 +294,17 @@ export function SideChatHost({ const openFor = useCallback( (item: SideChatPinnableItem) => { - // Check if an inline side-chat thread already exists for this item in the - // spec state cache. If so, route to the inline ThreadCollapsible instead - // of opening the popover (progressive popover retirement). + // Dismiss any open popover β€” the inline ThreadCollapsible is now the + // primary surface for all side-chat interactions (explore + edit). + if (activeRef.current) { + abortActiveStream(); + activeRef.current = null; + setActiveSideChat(null); + setActiveCards([]); + setPendingSpanHint(null); + } + + // Check if an inline side-chat thread already exists for this item. const specState = queryClient.getQueryData(specificationQueryKeys.bundle(String(specificationId))) as | SpecificationState | undefined; @@ -317,52 +312,28 @@ export function SideChatHost({ (t) => t.kind === 'side' && t.target_item_id === item.id && t.status === 'open', ); if (existingThread) { - // Dismiss any open popover β€” the inline thread is primary. - if (activeRef.current) { - abortActiveStream(); - activeRef.current = null; - setActiveSideChat(null); - setActiveCards([]); - setPendingSpanHint(null); - } setFocusedThreadItemId(item.id); return; } - // No thread yet β€” fall back to the popover, which still owns edit mode, - // patch staging, and annotation features the inline thread doesn't have. - // The popover's first message will create the thread server-side; next - // time the user clicks "Chat" on this item, the thread exists and we - // route inline above. - const current = activeRef.current; - if (current && current.itemKind === item.kind && current.itemId === item.id) { - const nextActiveSideChat = { - ...current, - pinnedItem: { referenceCode: item.referenceCode, content: item.content, kind: item.kind }, - }; - activeRef.current = nextActiveSideChat; - setActiveSideChat((active) => - active && active.itemKind === item.kind && active.itemId === item.id ? nextActiveSideChat : active, - ); - return; - } - - abortActiveStream(); - sessionCounterRef.current += 1; - setActiveCards([]); - setPendingSpanHint(null); - const nextActiveSideChat: ActiveSideChat = { - sessionId: sessionCounterRef.current, - pinnedItem: { referenceCode: item.referenceCode, content: item.content, kind: item.kind }, - itemKind: item.kind, - itemId: item.id, - messages: [], - messageTimestamps: [], - annotateMode: false, - mode: readStoredMode(), - }; - activeRef.current = nextActiveSideChat; - setActiveSideChat(nextActiveSideChat); + // No thread yet β€” create one eagerly so the ThreadCollapsible appears + // in the transcript immediately. + void (async () => { + try { + const res = await fetch(`/api/specifications/${specificationId}/threads`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetItemId: item.id }), + }); + if (!res.ok) return; + await queryClient.invalidateQueries({ + queryKey: specificationQueryKeys.bundle(String(specificationId)), + }); + setFocusedThreadItemId(item.id); + } catch { + // Network error β€” silently degrade; the user can retry. + } + })(); }, [abortActiveStream, specificationId], ); diff --git a/src/client/routes/specification/$id/__tests__/structured-list-view.test.tsx b/src/client/routes/specification/$id/__tests__/structured-list-view.test.tsx index f6bdcddb..0e8a9e47 100644 --- a/src/client/routes/specification/$id/__tests__/structured-list-view.test.tsx +++ b/src/client/routes/specification/$id/__tests__/structured-list-view.test.tsx @@ -649,7 +649,10 @@ describe('StructuredListView', () => { expect(mockNavigate).toHaveBeenCalledWith(expect.objectContaining({ hash: 'kind-goal' })); }); - describe('side-chat session', () => { + // side-chat session tests are skipped: openFor() now creates threads eagerly + // and routes to inline ThreadCollapsibles (FE-710 slice 9). These tests + // verified the retired popover-based flow. + describe.skip('side-chat session', () => { function makeManualStream() { let onChunk: ((event: SideChatStreamEvent) => void) | undefined; let resolveStream: () => void = () => {}; From d13346942db055f1b80e287730664d8a2c597017 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 23:45:00 +0200 Subject: [PATCH 14/19] FE-710: Update PLAN execution pointer after slices 8-9 Co-authored-by: Amp --- memory/PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index cfde9866..04e0dda6 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -83,7 +83,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - **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:** 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:** inline streaming + inline-first routing landed (ThreadCollapsible streams responses; "Chat" action routes to inline thread when one exists, popover fallback for first chat). Thread creation endpoint ready for eager creation. Next: in-thread edit mode + patch staging to unblock full popover retirement, or structural schema changes (`turn.thread_id` replaces `turn.chat_id`). +- **Current execution pointer:** popover retirement cutover complete β€” openFor always routes to inline ThreadCollapsible via eager thread creation (slices 5–9). Edit mode + patch staging in ThreadCollapsible. 46 popover tests skipped as retirement debt. Next: popover dead-code cleanup, or structural schema changes (`turn.thread_id` replaces `turn.chat_id`). ### reconciliation-runtime From c4dd4c4b4995bd8af1cfa1fcbe503adffbb51c48 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 14 May 2026 23:56:04 +0200 Subject: [PATCH 15/19] FE-710: Delete SideChatPopover and clean up dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 10: popover dead-code cleanup after the eager-creation cutover. Deleted: - side-chat-popover.tsx (795 LOC) + test (917 LOC) - side-chat-host.test.tsx (1583 LOC) β€” all tests tested retired popover - patch-list-overlay-bridge.tsx + patch-list-undo-context.tsx (popover-specific) Gutted side-chat-host.tsx from ~940 LOC to ~95 LOC: - Removed ActiveSideChat, ActiveCard, LoadedAnnotations types - Removed submitMessage, dismiss, annotate, mode, active cards, span hints - SideChatContextValue now: openFor, focusedThreadItemId, clearFocusedThread - Simplified render to just SideChatContext.Provider wrapping children Cleaned up patch-list-overlay.tsx: - Removed bridge and undo-override imports (deleted modules) - Apply button always applies all patches (no scoped bridge) Updated structured-list-view: - openWithSpanHint β†’ openFor (span hints retired) - Skipped annotate auto-apply test (auto-apply was in removed host code) Net: -3200 LOC deleted, 15 tests skipped as retirement debt. Co-authored-by: Amp --- .../__tests__/patch-list-overlay.test.tsx | 44 +- .../__tests__/side-chat-host.test.tsx | 1587 ----------------- .../__tests__/side-chat-popover.test.tsx | 917 ---------- src/client/components/impact-chip.tsx | 2 +- .../components/patch-list-overlay-bridge.tsx | 26 - src/client/components/patch-list-overlay.tsx | 20 +- .../components/patch-list-undo-context.tsx | 19 - src/client/components/side-chat-host.tsx | 863 +-------- src/client/components/side-chat-popover.tsx | 795 --------- src/client/components/thread-collapsible.tsx | 2 +- .../$id/-structured-list-view.tsx | 15 +- .../__tests__/structured-list-view.test.tsx | 5 +- 12 files changed, 23 insertions(+), 4272 deletions(-) delete mode 100644 src/client/components/__tests__/side-chat-host.test.tsx delete mode 100644 src/client/components/__tests__/side-chat-popover.test.tsx delete mode 100644 src/client/components/patch-list-overlay-bridge.tsx delete mode 100644 src/client/components/patch-list-undo-context.tsx delete mode 100644 src/client/components/side-chat-popover.tsx 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 554e9df9..00000000 --- a/src/client/components/__tests__/side-chat-host.test.tsx +++ /dev/null @@ -1,1587 +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(); -}); - -// All SideChatHost popover tests are skipped: openFor() now routes all -// side-chat interactions to inline ThreadCollapsibles via eager thread -// creation (FE-710 slice 9). The popover code and these tests are retirement -// debt β€” delete when the popover rendering is removed from SideChatHost. -describe.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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" - /> -