diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..978a2de0 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "typescript/no-deprecated": "error" + } +} diff --git a/drizzle.config.ts b/drizzle.config.ts index 4b23ea57..76a0c63e 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,10 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - out: './drizzle', - schema: './src/server/schema.ts', - dialect: 'sqlite', - dbCredentials: { - url: process.env.BRUNCH_DB || './brunch.db', - }, + out: './drizzle', + schema: './src/server/schema.ts', + dialect: 'sqlite', + dbCredentials: { + url: process.env.BRUNCH_DB || './brunch.db', + }, }); diff --git a/memory/PLAN.md b/memory/PLAN.md index eabe6068..4b961a83 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -54,26 +54,32 @@ ### Slices -6c. **Live streaming fix** — Fix the turn-card rendering regression: during live SSE streaming, the structured turn card does not render until page refresh. Thinking streams live; server persists correctly; hydration from DB works. Root cause is in the interaction between `toUIMessageStream()`, `useChat` part accumulation, and the tool-part lifecycle. `in-progress` +6c. **Live streaming fix** — Fix the turn-card rendering regression: during live SSE streaming, the structured turn card does not render until page refresh. Thinking streams live; server persists correctly; hydration from DB works. Root cause is in the interaction between `toUIMessageStream()`, `useChat` part accumulation, and the tool-part lifecycle. `done` - Requirements: → SPEC.md §Requirements #2, #3, #4 - Assumptions: → SPEC.md §Assumptions A16, A28 - Candidate invariant goals: live tool-part rendering matches persisted state after refresh - Invariants to respect: → SPEC.md §Invariants I16, I17, I18, I22 - Invariants established: → SPEC.md §Invariants I43 - Acceptance: send a message in dev, see the structured turn card appear live without refresh; `npm run verify` passes - - **Observed current state (2026-04-07, post-build):** the workspace controller now projects the latest streamed `tool-ask_question` input into the visible `TurnCard` before `onFinish` route invalidation, and targeted regression tests (`InterviewWorkspace`, `workspace-controller`, `workspace-data`, `app`) are green. The slice is still not safely retireable because manual browser verification is pending and `npm run verify` is currently blocked by unrelated repo-wide deprecation lint errors in `src/shared/chat.ts`, `src/client/components/ai-elements/prompt-input.tsx`, and `src/server/observer.ts`. + - **Observed current state (2026-04-07, post-build):** the workspace controller now projects the latest streamed `tool-ask_question` input into the visible `TurnCard` before `onFinish` route invalidation, targeted regression tests (`InterviewWorkspace`, `workspace-controller`, `workspace-data`, `app`) are green, the branch's latest full `npm run verify` passed before the docs-only SPEC commit, and manual browser verification confirmed the live turn card now appears without refresh. - **Observed code seam:** `InterviewWorkspace.renderParts()` still drops `tool-ask_question` transcript parts, but `workspace-controller-core.ts` now projects the latest streamed tool input into a temporary visible turn card while loading; durable loader state still owns the post-finish turn card after router invalidation. - - **Recommended next move for the implementing agent:** run a manual dev/browser walkthrough to confirm the turn card appears live in the real runtime, then retire 6c once the branch's unrelated lint baseline is resolved and `npm run verify` passes. + - **Recommended next move for the implementing agent:** retire 6c and move on to 6d's response-model remodeling work. - **Verification approach**: inner — unit/integration tests for tool-part state transitions or alternate live render path. Outer — manual interview: turn card renders live, matches post-refresh state. -6d. **Flexible turn-response model** — Replace the single-select answer assumption with typed response payloads that support zero/one/many selections, rationale, and custom answers. Keep structured interviewer guidance, recommendation, and strategic grounding, but stop assuming every turn maps to one categorical choice. `not-started` +6d. **Flexible turn-response model** — Replace the single-select answer assumption with typed turn responses that support zero/one/many selections plus unified free-text response content. Keep structured interviewer guidance, recommendation, and strategic grounding, but stop assuming every turn maps to one categorical choice or one scalar answer string. `done` - Requirements: → SPEC.md §Requirements #3, #6 - - Assumptions: → SPEC.md §Assumptions A16, A28 - - Decisions: → SPEC.md §Decisions D23, D24 + - Assumptions: → SPEC.md §Assumptions A16, A28, A33 + - Decisions: → SPEC.md §Decisions D23, D24, D25, D45, D46, D47, D48 - Candidate invariant goals: turn-response payload round-trip fidelity; multi-select/custom-answer state hydrates and replays correctly - Invariants to respect: → SPEC.md §Invariants I17, I18, I19, I22 - - Acceptance: a turn can be answered with multiple selections + rationale or with a custom answer; transcript, persistence, and resume stay aligned - - **Verification approach**: inner — schema + serialization tests for new prompt/response payloads. Outer — manual interview with multi-select and none-of-the-above answers. + - Invariants established: → SPEC.md §Invariants I44, I45, I46, I47 + - Acceptance: a turn can be answered with one-or-more selections plus optional free-text response or with zero selections plus required free-text response; transcript, persistence, interviewer context, and resume stay aligned + - **Observed current state (2026-04-07, tracer bullets 1–3):** zero/one/many selected options plus optional free-text now persist as `data-turn-response`, store a user-visible summary seam, rehydrate through the workspace path, and project into interviewer context coherently. The client turn card now stages many selections locally and submits them through the same turn-response seam as the other remodeled paths. + - **Verification approach**: inner — response-schema + projection characterization tests (`SPEC.md` §Verification Design, inner loop) prove cardinality and response-shaped context projection; middle — round-trip integration from submit → persistence → hydration → interviewer-context composition (`SPEC.md` §Verification Design, middle loop) validates A33 while protecting I17, I18, I19, and I22; outer — manual interview with zero/one/many option responses plus free-text-only replies confirms coherent follow-through (`SPEC.md` §Verification Design, outer loop). + - Tracer bullets: + - `6d.1` Single selected option + optional free-text response. `done` + - `6d.2` Zero selections + required free-text-only response. `done` + - `6d.3` True many-selection UX + persistence/hydration. `done` 6e. **Generic knowledge layer schema + sidebar projection** — Introduce the broader semantic layer (`framing`, `constraint`, `decision`, `assumption`, `requirement`, `criterion`) with generic provenance and graph edges, then project it cleanly into the sidebar without regressing existing reads. `not-started` - Requirements: → SPEC.md §Requirements #5, #6, #14 diff --git a/memory/SPEC.md b/memory/SPEC.md index 166d46db..3c62d571 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -68,22 +68,24 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. architecture (A1, A2, A5, A8–A13, A17–A19, A22–A27). Their truths are now structural properties of the codebase, not open questions. IDs are stable — gaps are intentional. --> -| # | Assumption | Confidence | Dependent decisions | Implicated slices | Validation approach | -| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A3 | Separating interviewer from observer produces better interview quality than inline tool calling | high | D1 | Observer agent | Spike confirms extraction is viable as separate call; interviewer prompt stays clean. | -| A4 | Observer extraction completes in 1-3s during user read/think time (10-60s), adding zero perceived latency | medium | D1 | Observer agent | Spike measured 14-17s with Sonnet. Haiku expected 2-5s — validate with `generateObject` model switch. | -| A6 | Turn-tree branching in SQLite is sufficient for decision revisit and undo in a single-user tool | high | D7 | Turn tree, Branching | Validate with realistic branch/merge scenarios | -| A7 | Users arriving at the tool have a reasonably defined goal | medium | — | Scope phase | User testing; characterization kickoff mode mitigates if false | -| A14 | A second-thread observer agent can reliably extract typed knowledge items and graph edges from a turn plus accumulated context | medium | D4, D5, D13 | Observer agent, Knowledge layer | Validated narrowly for decisions/assumptions; broadened ontology still needs probes across framing, constraints, requirements, and criteria-like signals. | -| A15 | The LLM can reliably judge when a workflow mode has reached sufficient closure to propose a phase outcome | medium | D3 | Phase resolution | Probe across varied project types; measure false-positive closure rate and user override frequency | -| A16 | AI SDK `useChat` hook's `ToolUIPart` state machine models all permutations of pending, error, and success for tool calls | high | D14 | Rich chat UI, 6c live streaming fix | Partially validated: typed `tool-ask_question` parts render with correct state labels, and live tool parts project into the visible turn card before route invalidation in `workspace-controller.test.tsx` and `InterviewWorkspace.test.tsx`. Browser outer-loop pending. | -| A20 | Observer results can be delivered as typed data parts on the existing chat stream without holding the connection open unacceptably long | high | D22 | Observer agent, Entity sidebar | Measure observer latency with `generateObject`; if >5s, fall back to out-of-band SSE | -| A21 | `useChat` `onData` callback reliably bridges to `queryClient.invalidateQueries` without stale-closure issues | **validated** | D22 | Entity sidebar | Validated: `InterviewWorkspace.test.tsx` covers `data-observer-result` → query invalidation → sidebar refresh, plus manual outer-loop verification remains for live browser/runtime behavior. | -| A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | -| A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | -| A30 | The client can detect when assistant content actually needs rich markdown or diagram enhancement and keep plain text rendering as the immediate default without creating a hydration or streaming mismatch | **validated** | D34, D36 | Refactor commit 4 — progressive rendering split | Validated by `src/client/capabilities/markdown-rendering.test.tsx` (plain path stays immediate, fenced code upgrades after lazy load) plus `src/client/build-boundary.test.ts` (entry excludes `streamdown` and eager highlighter implementation). | -| A31 | A workspace data adapter can centralize the boundary between durable project snapshots, durable entity snapshots, and ephemeral chat state without changing current user-visible behavior before concurrency and hydration policy changes land | **validated** | D37 | Refactor commits 5-7 — workspace state ownership | Validated by `src/client/workspace/workspace-data.test.ts` (durable vs ephemeral seed state separation is explicit and hydration timing is not owned by the adapter) plus unchanged green `src/client/routes/InterviewWorkspace.test.tsx` characterization coverage. | -| A32 | A project-scoped workspace loader can start durable project and entity snapshots together while seeding the entity query cache without reintroducing transcript hydration drift | **validated** | D38 | Refactor commit 6 — workspace loading concurrency | Validated by `src/client/routes/InterviewWorkspace.test.tsx` (initial sidebar data comes from the route loader with no post-mount entity fetch, same-project durable refresh updates sidebar state without rewriting the visible transcript, and observer-result invalidation still refetches entities through the same query boundary). | + + +| # | Assumption | Confidence | Dependent decisions | Implicated slices | Validation approach | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | --------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A3 | Separating interviewer from observer produces better interview quality than inline tool calling | high | D1 | Observer agent | Spike confirms extraction is viable as separate call; interviewer prompt stays clean. | +| A4 | Observer extraction completes in 1-3s during user read/think time (10-60s), adding zero perceived latency | medium | D1 | Observer agent | Spike measured 14-17s with Sonnet. Haiku expected 2-5s — validate with `generateObject` model switch. | +| A6 | Turn-tree branching in SQLite is sufficient for decision revisit and undo in a single-user tool | high | D7 | Turn tree, Branching | Validate with realistic branch/merge scenarios | +| A7 | Users arriving at the tool have a reasonably defined goal | medium | — | Scope phase | User testing; characterization kickoff mode mitigates if false | +| A14 | A second-thread observer agent can reliably extract typed knowledge items and graph edges from a turn plus accumulated context | medium | D4, D5, D13 | Observer agent, Knowledge layer | Validated narrowly for decisions/assumptions; broadened ontology still needs probes across framing, constraints, requirements, and criteria-like signals. | +| A15 | The LLM can reliably judge when a workflow mode has reached sufficient closure to propose a phase outcome | medium | D3 | Phase resolution | Probe across varied project types; measure false-positive closure rate and user override frequency | +| A16 | AI SDK `useChat` hook's `ToolUIPart` state machine models all permutations of pending, error, and success for tool calls | high | D14 | Rich chat UI, 6c live streaming fix | Partially validated: typed `tool-ask_question` parts render with correct state labels, live tool parts project into the visible turn card before route invalidation in `workspace-controller.test.tsx` and `InterviewWorkspace.test.tsx`, and manual browser verification confirmed the live turn card now appears without refresh. | +| A20 | Observer results can be delivered as typed data parts on the existing chat stream without holding the connection open unacceptably long | high | D22 | Observer agent, Entity sidebar | Measure observer latency with `generateObject`; if >5s, fall back to out-of-band SSE | +| A21 | `useChat` `onData` callback reliably bridges to `queryClient.invalidateQueries` without stale-closure issues | **validated** | D22 | Entity sidebar | Validated: `InterviewWorkspace.test.tsx` covers `data-observer-result` → query invalidation → sidebar refresh, plus manual outer-loop verification remains for live browser/runtime behavior. | +| A28 | AI SDK `ToolLoopAgent` with `stopWhen: stepCountIs(N)` is sufficient for brunch's multi-step interviewing, review, and phase-transition needs — no custom agent loop required | high | D31 | Agent loop, Phase transitions | Validate with mode-transition and review slices: agent must ask, synthesize, and propose closure without a handwritten loop. | +| A29 | Models can reliably compose generic filesystem tools (read, write, edit, bash, grep, find, ls) to explore and characterize an existing project | **validated** | D32 | Characterization kickoff | Validated (spike): `ToolLoopAgent` with 7 core tools explored brunch in 22 tool calls across 23 steps. See `spike/filesystem-tools.ts`. | +| A33 | Structured turn responses can replace today's single-select flow while keeping persisted response parts, transcript hydration, and interviewer-context projection aligned for the first thin slice | **validated** | D23, D24, D25, D45, D46, D47, D48 | 6d flexible turn-response model | Validated: `parts.test.ts`, `app.test.ts`, `context.test.ts`, and `InterviewWorkspace.test.tsx` now prove zero/one/many selected options plus optional free-text persist, rehydrate, and reach interviewer context coherently through the shared turn-response seam. | ## Decisions @@ -99,7 +101,7 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 35. **Developer debug surface is route-lazy, not startup-eager** — The `/debug` route remains declared in the main router, but its UI loads through a lazy client boundary so the default interview entrypoint does not inline developer-only debug content into the initial application chunk. This keeps the route available without charging normal startup for the debug surface. Depends on: D9, D34. Supersedes: eager debug-route component loading from the main router. -36. **Assistant rich rendering is progressive enhancement, not the baseline path** — Message and reasoning text render immediately through a plain text-safe boundary. Rich markdown, diagram rendering, and Shiki-backed highlighting load only after the content proves enhancement is needed, with the rich implementation and highlighter runtime emitted outside the default entry bundle. Depends on: D14, D34. Supersedes: startup-eager `streamdown` + highlighting on the default transcript path. +36. **Assistant rich rendering is progressive enhancement, not the baseline path** — Message and reasoning text render immediately through a plain text-safe boundary. The shipped app currently enhances only general markdown structure (plus lightweight rich rendering plugins such as math/CJK) after content proves enhancement is needed; mermaid graph rendering and syntax-highlighted code fences are intentionally out of scope for now because their bundle cost is not justified by current product needs. Depends on: D14, D34. Supersedes: startup-eager `streamdown` + highlighting on the default transcript path. 37. **Workspace state ownership lives behind a data adapter before semantics change** — The client reads workspace data through an explicit adapter that separates durable project snapshots, durable entity snapshots, and ephemeral chat seed state. This commit preserves current behavior, including the current project-scoped chat hydration boundary, while giving later commits one place to change fetch concurrency and hydration policy without another cross-cutting rewrite. Depends on: D19, D22. Supersedes: inline workspace ownership logic spread across `InterviewWorkspace` and `EntitySidebar`. @@ -117,6 +119,14 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 44. **Domain-shaped client mutations own success choreography above the shared transport seam** — `client-mutation.ts` remains the shared POST/error boundary, but project creation and turn-option selection now flow through domain hooks that own navigation, invalidation, and chat follow-through so route/controller callsites do not repeat workflow logic. Depends on: D40, D43. Supersedes: route- or controller-local success choreography on top of the generic mutation helper. +45. **Choice-turn responses persist as structured data plus a human-readable summary seam** — The single-option response path now writes a `data-turn-response` user part (`selectedOptionIds[]` + optional `freeText`) while `turn.answer` and the persisted text part carry a human-readable summary for transcript, observer, and transport seams. This keeps the response model structured without requiring a migration-hardening bridge for the old scalar-only worldview. Depends on: D23, D24. Supersedes: `data-option-selection` + scalar selected-option persistence. + +46. **Interviewer history prefers response-shaped projection when structured turn-response data exists** — `buildInterviewerContext(...)` should project prior choice-turn replies as chosen options plus free-text response when structured response data exists, falling back to scalar `Answer:` text only for older/non-structured turns. This gives the interviewer coherent access to remodeled response semantics without locking exact prompt prose too early. Depends on: D25, D45. Supersedes: scalar-only `Answer:` projection for choice-turn replies. + +47. **Zero-selection free-text responses reuse the same turn-response seam as option picks** — The existing choice-turn submit path now accepts either an option position or free-text-only content, but it always persists the same `data-turn-response` shape and emits the same chat-follow-through summary seam. Client naming should reflect “submit turn response,” not only “select option,” so the no-selection path is first-class rather than an exception case. Depends on: D24, D45. Supersedes: selection-only client/transport seam. + +48. **Choice-turn cards stage many selections locally and submit one array-shaped response seam** — Turn-card interaction now toggles zero/one/many selected options locally, then submits a single turn response carrying `positions[]` plus optional free-text through the same mutation/server boundary used by the other response paths. This keeps transcript hydration, persistence, and interviewer-context projection aligned without preserving the old immediate single-click selection behavior. Depends on: D45, D47. Supersedes: immediate single-option submit UI. + 26. **`md-pen` for programmatic markdown rendering** — Structured data (entity tables, dependency graphs, checklists) rendered to markdown via `md-pen` rather than hand-rolled string concatenation. Pure string-return functions (`table()`, `taskList()`, `mermaid()`, `heading()`, `alert()`, `details()`) compose by nesting — no AST, no intermediate representation. Escaping is context-aware per function (table cells, URLs, code fences), eliminating a class of bugs when rendering user-supplied text from interviews. Primary use cases: (1) observer context builders presenting growing entity graphs to agents (`table()` for decisions/assumptions with metadata, `taskList()` for reviewed/unreviewed items), (2) spec export rendering active-path entities into downloadable markdown (slice 13), (3) any future agent-facing or user-facing projection of structured data. Zero dependencies, ESM-only, TypeScript-first. Depends on: —. Supersedes: hand-rolled string assembly in context builders. ### Domain model @@ -131,8 +141,8 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. 13. **Observer captures typed knowledge items plus derived intelligence** — The observer's extraction mandate extends beyond decisions and assumptions to include framing facts, constraints, emerging requirements, criteria-like signals, and derived observations that the interviewer surfaced during the turn. These are persisted in the knowledge layer so subsequent context builders can inject them as context. Supersedes: decisions/assumptions-only observer ontology. 14. **Part-type rendering via AI Elements** — Client renders message parts using AI Elements copy-paste components: `Reasoning` (auto-open/close collapsible with duration), `MessageResponse` (streaming markdown via `streamdown`), `Tool` (7-state collapsible with status badges). `Conversation` provides auto-scroll. `PromptInput` provides `ChatStatus`-aware submit/stop button. shadcn/ui (radix-nova preset) + Tailwind 4 as the styling foundation. Depends on: A16, A17. Supersedes: hand-rolled inline-styled message rendering. 23. **Parts-based persistence model (UIMessage/ModelMessage split)** — Two separate data layers: (1) **UI render state** (`UIMessage.parts[]` JSON) persisted per turn for faithful resume — captures reasoning blocks, tool-call lifecycle states, text, and custom Data Parts. (2) **Inference context** (`ModelMessage`-equivalent) derived at call time by typed context builders, never persisted. The turn tree remains canonical for branching history; parts remain the source of truth for rendering. Prompt/response payload evolution can move independently of persisted UI parts. Research: `docs/research/chat-application-data-models-conversation-turns-structured-data-generative-ui-persistence.md`. Depends on: A22. Supersedes: D15's scalar-only persistence model. -24. **Custom Data Parts model structured user responses beyond single-select choice** — User responses are not always plain text or one categorical pick. AI SDK Data Parts model structured input such as zero/one/many option selections, rationale, custom answer overrides, confirmations, and later review actions. Assistant messages use the same mechanism for domain-specific output such as phase summaries, observer results, and entity snapshots. Depends on: A22, A23. Supersedes: implicit assumption that `turn.answer` is always a text string and that every structured answer is a single selected option. -25. **Typed context builders are phase-aware projections over history + knowledge + readiness** — Different consumers of the turn tree need different projections of the same underlying state. `buildInterviewerContext(...)` provides conversational continuity. `buildObserverContext(...)` provides extraction-optimized context over the current turn plus accumulated knowledge and relevant history summary. Future builders include readiness / review projections for phase-outcome proposal and review modes. Each builder reads from the domain model, not from persisted `UIMessage.parts[]`. Supersedes: single `formatHistory()` function in core.ts. +24. **Custom Data Parts model structured turn responses beyond single-select choice** — User replies are not always plain text or one categorical pick. AI SDK Data Parts model structured input such as zero/one/many option selections, unified free-text response content, confirmations, and later review actions. Assistant messages use the same mechanism for domain-specific output such as phase summaries, observer results, and entity snapshots. Depends on: A22, A23. Supersedes: implicit assumption that a turn's conceptual answer is always one text string and that every structured reply is a single selected option. +25. **Typed context builders are phase-aware projections over history + knowledge + readiness** — Different consumers of the turn tree need different projections of the same underlying state. `buildInterviewerContext(...)` provides conversational continuity. `buildObserverContext(...)` provides extraction-optimized context over the current turn plus accumulated knowledge and relevant history summary. Future builders include readiness / review projections for phase-outcome proposal and review modes. Builders read from the turn domain model first; while no dedicated response table exists, interviewer context may also read persisted structured user parts that are themselves the canonical response representation for a turn. Supersedes: single `formatHistory()` function in core.ts. ### Technical stack @@ -155,51 +165,55 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. Established by ln-build/ln-spike traceability. Referenced by PLAN.md slices (to establish / to respect). --> -| # | Invariant | Established by | Protected by | Proves | -| --- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------- | -| I1 | SSE protocol conformance | Slice 1 (skeleton) | app.test.ts | D8 | -| I2 | Stream lifecycle correctness | Slice 1 (skeleton) | app.test.ts | D8 | -| I3 | Thinking/text separation | Slice 1 (skeleton) | app.test.ts | D8 | -| I4 | Vite proxy routing | Slice 1 (skeleton) | vite.config.ts (manual) | D10 | -| I5 | DB lifecycle correctness | Slice 2 (SQLite) | db.test.ts | D7 | -| I6 | Turn persistence | Slice 3 (turn tree) | db.test.ts, app.test.ts | D1, D7 | -| I7 | Tool call SSE conformance | Slice 3b (rich UI) | app.test.ts, manual (outer loop) | D8, D14 | -| I8 | Tool part state rendering | Slice 3b (rich UI) | manual (outer loop) | D14 | -| I9 | Turn tree parent chain | Slice 3 (turn tree) | db.test.ts | D1 | -| I10 | Active path resolution | Slice 3 (turn tree) | db.test.ts | D1 | -| I11 | Drizzle migration auto-apply | Slice 3c (Drizzle) | db.test.ts | D18 | -| I12 | Typed server chat boundary | Slice 3c (Drizzle) | core.test.ts, app.test.ts | D19 | -| I13 | Core/adapter separation | Slice 3c (Drizzle) | core.test.ts, app.test.ts | D19 | -| I14 | Project-scoped API routes | Slice 3d (routing) | app.test.ts | D9 | -| I15 | Route loader hydration | Slice 3d (routing) | manual (outer loop) | D9 | -| I16 | Schema validation on agent tool output | Slice 4 (scope interview) | interview.test.ts | D2, A13 | -| I17 | Data Part schema validation | Slice 4a (parts persistence) | parts.test.ts (7 tests) | D24 | -| I18 | Parts round-trip fidelity | Slice 4a (parts persistence) | parts.test.ts (8 tests), core.test.ts | D23 | -| I19 | Context builder equivalence | Slice 4a (parts persistence) | context.test.ts (7 tests) | D25 | -| I20 | Entity persistence with turn linkage | Slice 5 (observer) | db.test.ts (7 tests), observer.test.ts | D4, D5 | -| I21 | Observer-result in-band sync | Slice 5 (observer) | observer.test.ts, app.test.ts | D22 | -| I22 | AI SDK-native interviewer path | Slice 6b (AI SDK pivot) | app.test.ts, interview.test.ts | D30, D31 | -| I23 | Entity sidebar reactive update | Slice 6 (sidebar) | app.test.ts, manual (outer loop) | D22 | -| I24 | Workspace hydration boundary stability | Slice 6b1 (workspace oracle) | InterviewWorkspace.test.tsx | D19, D22 | -| I25 | Workspace event bridge correctness | Slice 6b1 (workspace oracle) | InterviewWorkspace.test.tsx | D9, D22 | -| I26 | Progressive code-render fallback | Refactor commit 1 (client characterization coverage) | code-block.test.tsx | D14 | -| I27 | Equal-length branch replacement stability | Refactor commit 1 (client characterization coverage) | message.test.tsx | D14 | -| I28 | Client build boundary observability | Refactor commit 1 (client characterization coverage) | build-boundary.test.ts | — | -| I29 | Heavy client dependency indirection | Refactor commit 2 (client capability boundaries) | capability-boundaries.test.ts | D34 | -| I30 | Default entry excludes debug surface code | Refactor commit 3 (lazy debug route boundary) | build-boundary.test.ts | D35 | -| I31 | Assistant transcript rendering stays text-first until enhancement is needed | Refactor commit 4 (progressive rich rendering split) | markdown-rendering.test.tsx | D36 | -| I32 | Default entry excludes rich rendering and eager highlighting implementation | Refactor commit 4 (progressive rich rendering split) | build-boundary.test.ts | D36 | -| I33 | Workspace state ownership is explicit even while current hydration semantics are preserved | Refactor commit 5 (workspace data adapter) | workspace-data.test.ts, InterviewWorkspace.test.tsx | D37 | -| I34 | Workspace project and entity snapshots enter together through one project-scoped loader boundary | Refactor commit 6 (workspace loading concurrency) | InterviewWorkspace.test.tsx | D38 | -| I35 | Persisted chat state hydrates only on initial project entry or explicit project navigation | Refactor commit 7 (explicit chat hydration policy) | InterviewWorkspace.test.tsx, chat-hydration.test.ts | D39 | -| I36 | Client-triggered writes surface consistent visible failure states instead of silent no-ops | Refactor commit 8 (shared client mutations) | InterviewWorkspace.test.tsx, ProjectList.test.tsx | D40 | -| I37 | Code highlighting upgrades from lifecycle-owned async work and ignores stale completions during prop churn | Refactor commit 9 (render-sensitive primitive purity) | code-block.test.tsx | D41 | -| I38 | Message branch navigation stays aligned with the current branch set after replacement or shrink | Refactor commit 9 (render-sensitive primitive purity) | message.test.tsx | D41 | -| I39 | Advanced rendering boundaries expose intent-preload seams while keeping animated transcript content on the plain first-paint path | Refactor commit 10 (intent preloading + performance guardrails) | markdown-rendering.test.tsx, code-block.test.tsx, capability-boundaries.test.ts | D42 | -| I40 | The default client entry remains under an explicit size budget while excluding debug and rich-rendering payloads | Refactor commit 10 (intent preloading + performance guardrails) | build-boundary.test.ts | D42 | -| I41 | Workspace controller behavior is protected below the route boundary for loader seeding and same-project refresh stability | Refactor commit 14 (controller seam oracles) | workspace-controller.test.tsx | D43 | -| I42 | Shared client mutation transport reports network, non-JSON, and malformed-success failures consistently | Refactor commit 14 (mutation seam oracles) | client-mutation.test.ts | D44 | -| I43 | Live `tool-ask_question` parts project into the visible turn card before durable route refresh | Slice 6c (live streaming fix) | InterviewWorkspace.test.tsx, workspace-controller.test.tsx | D14, D43 | +| # | Invariant | Established by | Protected by | Proves | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------ | +| I1 | SSE protocol conformance | Slice 1 (skeleton) | app.test.ts | D8 | +| I2 | Stream lifecycle correctness | Slice 1 (skeleton) | app.test.ts | D8 | +| I3 | Thinking/text separation | Slice 1 (skeleton) | app.test.ts | D8 | +| I4 | Vite proxy routing | Slice 1 (skeleton) | vite.config.ts (manual) | D10 | +| I5 | DB lifecycle correctness | Slice 2 (SQLite) | db.test.ts | D7 | +| I6 | Turn persistence | Slice 3 (turn tree) | db.test.ts, app.test.ts | D1, D7 | +| I7 | Tool call SSE conformance | Slice 3b (rich UI) | app.test.ts, manual (outer loop) | D8, D14 | +| I8 | Tool part state rendering | Slice 3b (rich UI) | manual (outer loop) | D14 | +| I9 | Turn tree parent chain | Slice 3 (turn tree) | db.test.ts | D1 | +| I10 | Active path resolution | Slice 3 (turn tree) | db.test.ts | D1 | +| I11 | Drizzle migration auto-apply | Slice 3c (Drizzle) | db.test.ts | D18 | +| I12 | Typed server chat boundary | Slice 3c (Drizzle) | core.test.ts, app.test.ts | D19 | +| I13 | Core/adapter separation | Slice 3c (Drizzle) | core.test.ts, app.test.ts | D19 | +| I14 | Project-scoped API routes | Slice 3d (routing) | app.test.ts | D9 | +| I15 | Route loader hydration | Slice 3d (routing) | manual (outer loop) | D9 | +| I16 | Schema validation on agent tool output | Slice 4 (scope interview) | interview.test.ts | D2, A13 | +| I17 | Data Part schema validation | Slice 4a (parts persistence) | parts.test.ts (7 tests) | D24 | +| I18 | Parts round-trip fidelity | Slice 4a (parts persistence) | parts.test.ts (7 tests), core.test.ts | D23 | +| I19 | Context builder equivalence | Slice 4a (parts persistence) | context.test.ts (9 tests) | D25 | +| I20 | Entity persistence with turn linkage | Slice 5 (observer) | db.test.ts (7 tests), observer.test.ts | D4, D5 | +| I21 | Observer-result in-band sync | Slice 5 (observer) | observer.test.ts, app.test.ts | D22 | +| I22 | AI SDK-native interviewer path | Slice 6b (AI SDK pivot) | app.test.ts, interview.test.ts | D30, D31 | +| I23 | Entity sidebar reactive update | Slice 6 (sidebar) | app.test.ts, manual (outer loop) | D22 | +| I24 | Workspace hydration boundary stability | Slice 6b1 (workspace oracle) | InterviewWorkspace.test.tsx | D19, D22 | +| I25 | Workspace event bridge correctness | Slice 6b1 (workspace oracle) | InterviewWorkspace.test.tsx | D9, D22 | +| I26 | Progressive code-render fallback | Refactor commit 1 (client characterization coverage) | code-block.test.tsx | D14 | +| I27 | Equal-length branch replacement stability | Refactor commit 1 (client characterization coverage) | message.test.tsx | D14 | +| I28 | Client build boundary observability | Refactor commit 1 (client characterization coverage) | build-boundary.test.ts | — | +| I29 | Heavy client dependency indirection | Refactor commit 2 (client capability boundaries) | capability-boundaries.test.ts | D34 | +| I30 | Default entry excludes debug surface code | Refactor commit 3 (lazy debug route boundary) | build-boundary.test.ts | D35 | +| I31 | Assistant transcript rendering stays text-first until enhancement is needed | Refactor commit 4 (progressive rich rendering split) | markdown-rendering.test.tsx | D36 | +| I32 | Default entry excludes rich rendering and eager highlighting implementation | Refactor commit 4 (progressive rich rendering split) | build-boundary.test.ts | D36 | +| I33 | Workspace state ownership is explicit even while current hydration semantics are preserved | Refactor commit 5 (workspace data adapter) | workspace-data.test.ts, InterviewWorkspace.test.tsx | D37 | +| I34 | Workspace project and entity snapshots enter together through one project-scoped loader boundary | Refactor commit 6 (workspace loading concurrency) | InterviewWorkspace.test.tsx | D38 | +| I35 | Persisted chat state hydrates only on initial project entry or explicit project navigation | Refactor commit 7 (explicit chat hydration policy) | InterviewWorkspace.test.tsx, chat-hydration.test.ts | D39 | +| I36 | Client-triggered writes surface consistent visible failure states instead of silent no-ops | Refactor commit 8 (shared client mutations) | InterviewWorkspace.test.tsx, ProjectList.test.tsx | D40 | +| I37 | Code highlighting upgrades from lifecycle-owned async work and ignores stale completions during prop churn | Refactor commit 9 (render-sensitive primitive purity) | code-block.test.tsx | D41 | +| I38 | Message branch navigation stays aligned with the current branch set after replacement or shrink | Refactor commit 9 (render-sensitive primitive purity) | message.test.tsx | D41 | +| I39 | Advanced rendering boundaries expose intent-preload seams while keeping animated transcript content on the plain first-paint path | Refactor commit 10 (intent preloading + performance guardrails) | markdown-rendering.test.tsx, code-block.test.tsx, capability-boundaries.test.ts | D42 | +| I40 | The default client entry remains under an explicit size budget while excluding debug and rich-rendering payloads | Refactor commit 10 (intent preloading + performance guardrails) | build-boundary.test.ts | D42 | +| I41 | Workspace controller behavior is protected below the route boundary for loader seeding and same-project refresh stability | Refactor commit 14 (controller seam oracles) | workspace-controller.test.tsx | D43 | +| I42 | Shared client mutation transport reports network, non-JSON, and malformed-success failures consistently | Refactor commit 14 (mutation seam oracles) | client-mutation.test.ts | D44 | +| I43 | Live `tool-ask_question` parts project into the visible turn card before durable route refresh | Slice 6c (live streaming fix) | InterviewWorkspace.test.tsx, workspace-controller.test.tsx | D14, D43 | +| I44 | Choice-turn replies persist as structured turn-response parts plus a user-visible summary on the first two remodeled response paths | Slice 6d.1 / 6d.2 (single-option + free-text; free-text-only) | parts.test.ts, app.test.ts, InterviewWorkspace.test.tsx | D24, D45 | +| I45 | Interviewer history projects chosen options and/or free-text from structured turn responses when available | Slice 6d.1 / 6d.2 (single-option + free-text; free-text-only) | context.test.ts | D25, D46 | +| I46 | Free-text-only choice-turn replies require non-empty text and submit through the same turn-response seam as option picks | Slice 6d.2 (zero-selection + required free-text) | parts.test.ts, app.test.ts, InterviewWorkspace.test.tsx | D24, D47 | +| I47 | Choice-turn replies can stage and persist many selected options as one structured turn response without collapsing back to scalar selection semantics | Slice 6d.3 (many-selection UX + persistence/hydration) | app.test.ts, parts.test.ts, context.test.ts, InterviewWorkspace.test.tsx | D24, D45, D46, D48 | ## Lexicon @@ -226,8 +240,10 @@ Detailed schema and mode-model rationale: `docs/design/INTERVIEW_MODE_MODEL.md`. | **turn** | A branching checkpoint in the interview history. Carries phase provenance plus typed interaction payloads and UI parts. Points to its parent turn — the turn tree is the version history. | | **active path** | The branch from HEAD to root in the turn tree. Determines which turns, knowledge items, phase outcomes, and review state are currently trusted. | | **phase** / **mode** | A workflow stage of the interview: `scope`, `design`, `requirements`, `criteria`. Modes change interviewer behavior, observer extraction bias, and closure logic. They are not exclusive capture windows. | -| **choice turn** | An exploratory interaction turn where the interviewer proposes structured options and strategic grounding. Supports zero/one/many selections, rationale, and custom answers. | +| **choice turn** | An exploratory interaction turn where the interviewer proposes structured options and strategic grounding. Supports zero/one/many selections plus a unified free-text response field that is optional when options are chosen and required when none are chosen. | | **review turn** | A review interaction turn where the interviewer asks the user to approve, edit, reject, merge, or add to a synthesized item set. | +| **turn response** | The full structured user reply to a turn: chosen options plus optional/required free-text content. This is the conceptual answer shape even when compatibility scalars exist in storage or transport seams. | +| **free-text response** | User-authored text attached to a turn response. It can supplement chosen options or stand alone when no option fits. | | **framing** | A contextual truth or intent statement: project goal, actor, user need, workflow context, domain fact, or problem statement. | | **constraint** | A boundary on the acceptable solution space, including hard limits, exclusions, and non-goals. | | **decision** | A chosen fork or commitment in the design tree. Depends on earlier knowledge and can carry rationale. | @@ -282,11 +298,11 @@ Verification is not a phase that follows implementation — it is integral to ev Scored per the arc-oracle diagnostic framework (high / partial / low): -| Dimension | Score | Notes | -| ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Observability** | partial | Inner/middle: high (all text-native — tests, SSE, DB). Outer: low for observer extraction quality (hidden from surface UI) and LLM judgment calls (phase resolution, interview quality). Mitigated by debug mode (planned) and differential testing (spike). | -| **Reproducibility** | partial | Deterministic systems (turn tree, DB, SSE encoding): high. LLM boundary (interviewer output, observer extraction): low — non-deterministic. Mitigated by schema validation (structural) and golden master fixtures with capture-rate thresholds (statistical). | -| **Controllability** | high | Single-user, local SQLite, no external dependencies beyond Claude API. Agent drives full inner loop autonomously (`npm run fix` / `npm run verify`). Human review reserved for outer loop. | +| Dimension | Score | Notes | +| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Observability** | partial | Inner/middle: high for persisted response structure, hydration state, and interviewer-context projection because they are text-native and testable. Outer: still partial for interviewer follow-through quality, which remains visible only through runtime interaction. | +| **Reproducibility** | partial | Deterministic systems (turn tree, DB, schema validation, context projection): high. LLM boundary (interviewer output, observer extraction): low — non-deterministic. For slice 6d we therefore prove structural coherence in middle loop and defer qualitative coherence to the outer loop. | +| **Controllability** | high | Single-user, local SQLite, no external dependencies beyond Claude API. Agent drives full inner loop autonomously (`npm run fix` / `npm run verify`). Human review reserved for outer loop. | ### Verification Commands @@ -312,35 +328,54 @@ End-to-end slices must be **user-testable**, not just programmatically tested. E **Inner loop** (ms–seconds): agent-autonomous, always-on -| Oracle family | What it proves | Protects | Cost | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------------------------------------- | -| Schema validation | Agent tool output conforms to the active turn/review payload schema for the current mode | I16 (planned) | Negligible — Zod parse on tool output | -| Fast unit tests — SSE | `SDKMessage` → correct SSE event strings | I1, I3, I7 | ms | -| Fast unit tests — DB | Turn persistence with phase provenance, entity writes with dependency edges | I5, I6, I9, I10, I11 | ms | -| Fast unit tests — core | DomainEvent streaming, core/adapter separation, structured turn creation | I12, I13 | ms | -| Fast unit tests — parts | Parts round-trip (DomainEvents → assemble → persist JSON → load → hydrate); Data Part schema validation (Zod parse on structured user input); context builder output shape | I17, I18, I19 | ms | -| Fast unit tests — observer sync | `observer-complete` emitted post-commit with entity IDs matching DB state; SSE adapter encodes as typed data part | D22, A20 | ms | -| Type-aware linting | Semantic static checks (oxlint + tsgolint) | All | ms | +| Oracle family | What it proves | Protects | Cost | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------- | +| Schema validation | Agent tool output conforms to the active turn/review/response payload schema for the current mode | I16 (planned), I17 | Negligible — Zod parse on tool output | +| Fast unit tests — SSE | `SDKMessage` → correct SSE event strings | I1, I3, I7 | ms | +| Fast unit tests — DB | Turn persistence with phase provenance, entity writes with dependency edges | I5, I6, I9, I10, I11 | ms | +| Fast unit tests — core | DomainEvent streaming, core/adapter separation, structured turn creation | I12, I13 | ms | +| Fast unit tests — parts | Parts round-trip (DomainEvents → assemble → persist JSON → load → hydrate); Data Part schema validation (Zod parse on structured user input); context builder output shape | I17, I18, I19 | ms | +| Fast unit tests — turn response | Structured turn-response schema and submit seams establish zero/one/many selected-option arrays plus the required-free-text rule; interviewer context projection stays response-shaped, not scalar-only | I17, I18, I19, I44, I45, I46, I47, A33 | ms | +| Fast unit tests — observer sync | `observer-complete` emitted post-commit with entity IDs matching DB state; SSE adapter encodes as typed data part | D22, A20 | ms | +| Type-aware linting | Semantic static checks (oxlint + tsgolint) | All | ms | **Middle loop** (seconds–minutes): regression gates -| Oracle family | What it proves | Protects | Cost | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------- | -| Differential testing (observer) | Observer extraction meets ≥80% entity capture rate against golden master fixtures | A14 | seconds per fixture; requires Claude API | -| Round-trip oracle (turn tree) | Structured turns → active path → entity resolution intact | I6, I9, I10 | ms | -| Integration tests | SSE stream contains expected event types in order; DB lifecycle survives close/reopen | I2, I5, I13, I14 | seconds | -| Round-trip oracle (observer sync) | Full `conductTurn()` with observer → `observer-complete` is last event before `stream-end` → entity IDs in event match committed DB rows | D22 | seconds; requires Claude API | +| Oracle family | What it proves | Protects | Cost | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------- | +| Differential testing (observer) | Observer extraction meets ≥80% entity capture rate against golden master fixtures | A14 | seconds per fixture; requires Claude API | +| Round-trip oracle (turn response) | Structured turn response survives submit → persistence → hydration → interviewer-context composition with no drift | I17, I18, I19, I22, I44, I45, I46, I47, A33 | seconds | +| Round-trip oracle (turn tree) | Structured turns → active path → entity resolution intact | I6, I9, I10 | ms | +| Integration tests | SSE stream contains expected event types in order; DB lifecycle survives close/reopen | I2, I5, I13, I14 | seconds | +| Round-trip oracle (observer sync) | Full `conductTurn()` with observer → `observer-complete` is last event before `stream-end` → entity IDs in event match committed DB rows | D22 | seconds; requires Claude API | **Outer loop** (minutes–hours): human observer -| Oracle family | What it proves | Cost | -| -------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------- | -| Debug mode (observer visibility) | Observer extraction is inspectable per-turn during manual testing | UI delta on slice 5/6 | -| Manual interview walkthrough | Structured questions render correctly; interview quality is acceptable | Human time | -| Fixture capture from manual runs | Bootstrap golden master fixtures by querying DB after confirmed-good sessions | Human judgment + SQL query | -| Rich chat rendering | Tool call states, reasoning collapse, message parts render by type | Human + `/cli-cdp` | -| Resume test | Close/reopen browser, verify state intact | Human + browser | -| Observer → sidebar reactivity | `onData` → `setQueryData` bridge updates sidebar after observer extraction; validates A21 | Human + `/cli-cdp` (slice 6) | +| Oracle family | What it proves | Cost | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| Debug mode (observer visibility) | Observer extraction is inspectable per-turn during manual testing | UI delta on slice 5/6 | +| Manual interview walkthrough — turn response | Zero/one/many option responses plus free-text-only replies remain coherent in runtime and give the interviewer enough structured context for a sensible follow-up | Human + browser | +| Manual interview walkthrough | Structured questions render correctly; interview quality is acceptable | Human time | +| Fixture capture from manual runs | Bootstrap golden master fixtures by querying DB after confirmed-good sessions | Human judgment + SQL query | +| Rich chat rendering | Tool call states, reasoning collapse, message parts render by type | Human + `/cli-cdp` | +| Resume test | Close/reopen browser, verify state intact | Human + browser | +| Observer → sidebar reactivity | `onData` → query invalidation updates sidebar after observer extraction; validates A21 | Human + `/cli-cdp` (slice 6) | + +### Design notes + +- **6d response-model oracle boundary** — Middle-loop oracles for slice 6d prove structural coherence only: the same turn response shape must survive submit, persistence, hydration, and interviewer-context projection. They do not attempt to score the quality of the next interviewer turn. +- **Unified free-text field** — For slice 6d, rationale and custom-answer semantics are intentionally unified into one free-text response field. The oracle locks the cardinality rule: free text is optional when at least one option is chosen and required when zero options are chosen. +- **Response-shaped projection over scalar answer wording** — The interviewer projection oracle should lock grouping and presence of chosen options and free-text content, but not exact prose. The goal is to move away from scalar-only `Answer:` semantics without overfitting prompt wording too early. +- **No bridge oracle for `turn.answer`** — Because the project is still early and a breaking change is acceptable, oracle design does not spend budget on proving compatibility behavior for a scalar `turn.answer` seam. + +### Acknowledged Blind Spots + +| Blind spot | Reason | Mitigation | Revisit trigger | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Next-turn interviewer quality after response remodeling | Qualitative LLM behavior is non-deterministic and too expensive to gate in the middle loop while the response shape is still moving | Manual interview walkthrough in outer loop | Revisit once response shape stabilizes across several remodelings | +| Exact interviewer projection wording | Prompt prose is still evolving; locking exact labels now would create churn without much signal | Lock structure/grouping rather than exact text in projection tests | Revisit when prompt vocabulary and transcript copy settle | +| Full breadth of future response variants | Slice 6d starts by proving the general response-model seam, not every future UI variant or review action | Keep schema and projection extensible; add focused slice oracles as variants land | Revisit when multi-select UX and later review actions are implemented | +| Legacy scalar-answer compatibility | Existing data is not important enough to justify migration hardening, and a breaking change is acceptable | Skip bridge oracle; refactor directly toward the structured response model | Revisit only if an external consumer starts depending on the scalar seam | ### Observer History Projection @@ -369,26 +404,26 @@ This projection difference is a deliberate design choice, not an implementation -| File | Tests | Protects | -| ----------------------------- | ----- | -------------------------------------- | -| db.test.ts | 32 | I5, I6, I9, I10, I11, I20 | -| app.test.ts | 6 | I1, I2, I3, I7, I14, I21, I23 | -| core.test.ts | 6 | I12, I13, I18 | -| interview.test.ts | 6 | I16 | -| parts.test.ts | 7 | I17, I18 | -| context.test.ts | 8 | I19 | -| observer.test.ts | 2 | I20, I21 | -| InterviewWorkspace.test.tsx | 7 | I24, I25, I23, I33, I34, I35, I36, I43 | -| ProjectList.test.tsx | 2 | I36 | -| workspace-data.test.ts | 4 | I33 | -| chat-hydration.test.ts | 3 | I35 | -| workspace-controller.test.tsx | 3 | I41, I43 | -| client-mutation.test.ts | 3 | I42 | -| code-block.test.tsx | 4 | I26, I37, I39 | -| markdown-rendering.test.tsx | 3 | I31, I39 | -| message.test.tsx | 2 | I27, I38 | -| build-boundary.test.ts | 1 | I28, I30, I32, I40 | -| capability-boundaries.test.ts | 2 | I29, I39 | +| File | Tests | Protects | +| ----------------------------- | ----- | ----------------------------------------------------- | +| db.test.ts | 32 | I5, I6, I9, I10, I11, I20 | +| app.test.ts | 9 | I1, I2, I3, I7, I14, I21, I23, I44, I46, I47 | +| core.test.ts | 6 | I12, I13, I18 | +| interview.test.ts | 6 | I16 | +| parts.test.ts | 10 | I17, I18, I44, I46, I47 | +| context.test.ts | 11 | I19, I45, I47 | +| observer.test.ts | 2 | I20, I21 | +| InterviewWorkspace.test.tsx | 9 | I24, I25, I23, I33, I34, I35, I36, I43, I44, I46, I47 | +| ProjectList.test.tsx | 2 | I36 | +| workspace-data.test.ts | 4 | I33 | +| chat-hydration.test.ts | 3 | I35 | +| workspace-controller.test.tsx | 3 | I41, I43 | +| client-mutation.test.ts | 3 | I42 | +| code-block.test.tsx | 4 | I26, I37, I39 | +| markdown-rendering.test.tsx | 3 | I31, I39 | +| message.test.tsx | 2 | I27, I38 | +| build-boundary.test.ts | 1 | I28, I30, I32, I40 | +| capability-boundaries.test.ts | 2 | I29, I39 | ## Acceptance Criteria (exit conditions) diff --git a/package.json b/package.json index a7e774a6..c417b577 100644 --- a/package.json +++ b/package.json @@ -1,87 +1,88 @@ { - "name": "@hashintel/brunch", - "private": true, - "description": "AI chat interface built on HASH", - "homepage": "https://github.com/hashintel/brunch#readme", - "bugs": { - "url": "https://github.com/hashintel/brunch/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/hashintel/brunch.git" - }, - "license": "(MIT OR Apache-2.0)", - "type": "module", - "scripts": { - "build": "vite build", - "check": "npm run fmt:check && npm run lint", - "dev": "agent-tail run 'vite: lsof -ti:5173 | xargs kill -9 2>/dev/null; vite' 'api: lsof -ti:3000 | xargs kill -9 2>/dev/null; npx tsx --env-file=.env --watch src/server/index.ts'", - "fix": "npm run lint:fix && npm run fmt", - "fmt": "oxfmt src/", - "fmt:check": "oxfmt --check src/", - "lint": "oxlint --type-aware --type-check src/", - "lint:fix": "oxlint --type-aware --type-check --fix src/", - "server": "npx tsx --env-file=.env src/server/index.ts", - "studio": "drizzle-kit studio", - "test": "vitest run", - "verify": "npm run check && npm run test && npm run build" - }, - "dependencies": { - "@ai-sdk/anthropic": "^3.0.66", - "@ai-sdk/react": "^3.0.145", - "@anthropic-ai/sdk": "^0.82.0", - "@fontsource-variable/geist": "^5.2.8", - "@modelcontextprotocol/sdk": "^1.27.1", - "@radix-ui/react-use-controllable-state": "^1.2.2", - "@streamdown/cjk": "^1.0.3", - "@streamdown/code": "^1.1.1", - "@streamdown/math": "^1.0.2", - "@streamdown/mermaid": "^1.0.2", - "@tailwindcss/vite": "^4.2.2", - "@tanstack/react-query": "^5.96.1", - "@tanstack/react-router": "^1.168.10", - "@vitejs/plugin-react": "^5.2.0", - "ai": "^6.0.143", - "better-sqlite3": "^12.8.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "drizzle-orm": "^0.45.2", - "express": "^5.2.1", - "lucide-react": "^1.7.0", - "md-pen": "^1.2.0", - "motion": "^12.38.0", - "nanoid": "^5.1.7", - "radix-ui": "^1.4.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "shiki": "^4.0.2", - "streamdown": "^2.5.0", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", - "tw-animate-css": "^1.4.0", - "use-stick-to-bottom": "^1.1.3", - "zod": "^4.3.6" - }, - "devDependencies": { - "@testing-library/react": "^16.3.2", - "@types/better-sqlite3": "^7.6.13", - "@types/express": "^5.0.6", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@types/supertest": "^7.2.0", - "agent-tail": "^0.4.0", - "concurrently": "^9.2.1", - "drizzle-kit": "^0.31.10", - "happy-dom": "^20.8.9", - "oxfmt": "^0.43.0", - "oxlint": "^1.58.0", - "oxlint-tsgolint": "^0.19.0", - "shadcn": "^4.1.2", - "supertest": "^7.2.2", - "tsx": "^4.21.0", - "typescript": "^5.9.3", - "vite": "^7.0.4", - "vitest": "^4.1.0" - } + "name": "@hashintel/brunch", + "private": true, + "description": "AI chat interface built on HASH", + "homepage": "https://github.com/hashintel/brunch#readme", + "bugs": { + "url": "https://github.com/hashintel/brunch/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/hashintel/brunch.git" + }, + "license": "(MIT OR Apache-2.0)", + "type": "module", + "scripts": { + "build": "vite build", + "check": "npm run fmt:check && npm run lint && npm run typecheck", + "dev": "agent-tail run 'vite: lsof -ti:5173 | xargs kill -9 2>/dev/null; vite' 'api: lsof -ti:3000 | xargs kill -9 2>/dev/null; npx tsx --env-file=.env --watch src/server/index.ts'", + "fix": "npm run lint:fix && npm run fmt", + "fmt": "oxfmt src/ vite.config.ts drizzle.config.ts", + "fmt:check": "oxfmt --check src/ vite.config.ts drizzle.config.ts", + "lint": "oxlint --type-aware --type-check src/ vite.config.ts drizzle.config.ts", + "lint:fix": "oxlint --type-aware --type-check --fix src/ vite.config.ts drizzle.config.ts", + "server": "npx tsx --env-file=.env src/server/index.ts", + "studio": "drizzle-kit studio", + "test": "vitest run", + "typecheck": "tsc --noEmit --project tsconfig.tools.json", + "verify": "npm run check && npm run test && npm run build" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.66", + "@ai-sdk/react": "^3.0.145", + "@anthropic-ai/sdk": "^0.82.0", + "@fontsource-variable/geist": "^5.2.8", + "@modelcontextprotocol/sdk": "^1.27.1", + "@radix-ui/react-use-controllable-state": "^1.2.2", + "@streamdown/cjk": "^1.0.3", + "@streamdown/code": "^1.1.1", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", + "@tailwindcss/vite": "^4.2.2", + "@tanstack/react-query": "^5.96.1", + "@tanstack/react-router": "^1.168.10", + "@vitejs/plugin-react": "^5.2.0", + "ai": "^6.0.143", + "better-sqlite3": "^12.8.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "drizzle-orm": "^0.45.2", + "express": "^5.2.1", + "lucide-react": "^1.7.0", + "md-pen": "^1.2.0", + "motion": "^12.38.0", + "nanoid": "^5.1.7", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "shiki": "^4.0.2", + "streamdown": "^2.5.0", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "use-stick-to-bottom": "^1.1.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.6", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/supertest": "^7.2.0", + "agent-tail": "^0.4.0", + "concurrently": "^9.2.1", + "drizzle-kit": "^0.31.10", + "happy-dom": "^20.8.9", + "oxfmt": "^0.43.0", + "oxlint": "^1.58.0", + "oxlint-tsgolint": "^0.19.0", + "shadcn": "^4.1.2", + "supertest": "^7.2.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^7.0.4", + "vitest": "^4.1.0" + } } diff --git a/src/client/capabilities/code-highlighting.ts b/src/client/capabilities/code-highlighting.ts index d3024524..57e70408 100644 --- a/src/client/capabilities/code-highlighting.ts +++ b/src/client/capabilities/code-highlighting.ts @@ -1,8 +1,10 @@ 'use client'; -import type { BundledLanguage, ThemedToken } from 'shiki'; +import type { ThemedToken } from 'shiki'; -export type CodeLanguage = BundledLanguage; +export const SUPPORTED_CODE_LANGUAGES = ['json', 'text', 'typescript'] as const; + +export type CodeLanguage = (typeof SUPPORTED_CODE_LANGUAGES)[number]; export type CodeToken = ThemedToken; export interface TokenizedCode { diff --git a/src/client/capabilities/markdown-rendering.test.tsx b/src/client/capabilities/markdown-rendering.test.tsx index faba67f9..a8aff17e 100644 --- a/src/client/capabilities/markdown-rendering.test.tsx +++ b/src/client/capabilities/markdown-rendering.test.tsx @@ -47,22 +47,29 @@ describe('MarkdownRenderer', () => { it('keeps rich markdown on the plain first-paint path while the message is animating', async () => { const { MarkdownRenderer } = await import('./markdown-rendering.js'); const content = '```typescript\nconst answer = 42\n```'; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { container, rerender } = render({content}); + try { + const { container, rerender } = render({content}); - expect(container.querySelector('[data-rendering-mode="plain"]')?.textContent).toContain( - 'const answer = 42', - ); + expect(container.querySelector('[data-rendering-mode="plain"]')?.textContent).toContain( + 'const answer = 42', + ); - await Promise.resolve(); - expect(container.querySelector('[data-rendering-mode="rich"]')).toBeNull(); + await Promise.resolve(); + expect(container.querySelector('[data-rendering-mode="rich"]')).toBeNull(); - rerender({content}); + rerender({content}); - await waitFor(() => { - expect(container.querySelector('[data-rendering-mode="rich"]')?.textContent).toContain( - 'const answer = 42', - ); - }); + await waitFor(() => { + expect(container.querySelector('[data-rendering-mode="rich"]')?.textContent).toContain( + 'const answer = 42', + ); + }); + + expect(consoleError.mock.calls.some((call) => String(call[0]).includes('isAnimating'))).toBe(false); + } finally { + consoleError.mockRestore(); + } }); }); diff --git a/src/client/capabilities/rich-code-highlighting.ts b/src/client/capabilities/rich-code-highlighting.ts index c030b0f6..7d55842f 100644 --- a/src/client/capabilities/rich-code-highlighting.ts +++ b/src/client/capabilities/rich-code-highlighting.ts @@ -2,9 +2,11 @@ import type { BundledTheme, HighlighterGeneric } from 'shiki'; import { createHighlighter } from 'shiki'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import type { CodeLanguage, TokenizedCode } from './code-highlighting'; +const regexEngine = createJavaScriptRegexEngine({ forgiving: true }); const highlighterCache = new Map>>(); const getHighlighter = (language: CodeLanguage): Promise> => { @@ -14,9 +16,10 @@ const getHighlighter = (language: CodeLanguage): Promise>; highlighterCache.set(language, highlighterPromise); return highlighterPromise; diff --git a/src/client/capabilities/rich-markdown-rendering.tsx b/src/client/capabilities/rich-markdown-rendering.tsx index 34139203..17d580ef 100644 --- a/src/client/capabilities/rich-markdown-rendering.tsx +++ b/src/client/capabilities/rich-markdown-rendering.tsx @@ -1,16 +1,18 @@ 'use client'; import { cjk } from '@streamdown/cjk'; -import { code } from '@streamdown/code'; import { math } from '@streamdown/math'; -import { mermaid } from '@streamdown/mermaid'; import { Streamdown } from 'streamdown'; import type { MarkdownRendererProps } from './markdown-rendering'; -const markdownRenderingPlugins = { cjk, code, math, mermaid }; +const markdownRenderingPlugins = { cjk, math }; -export const RichMarkdownRenderer = ({ children, ...props }: MarkdownRendererProps) => ( +export const RichMarkdownRenderer = ({ + children, + isAnimating: _isAnimating, + ...props +}: MarkdownRendererProps) => (
{children} diff --git a/src/client/capability-boundaries.test.ts b/src/client/capability-boundaries.test.ts index 71eca20c..245db010 100644 --- a/src/client/capability-boundaries.test.ts +++ b/src/client/capability-boundaries.test.ts @@ -20,7 +20,8 @@ describe('client capability boundaries', () => { expect(markdownCapabilitySource).toContain('export const preloadRichMarkdownRenderer'); expect(markdownCapabilitySource).not.toContain("from 'streamdown'"); expect(richMarkdownCapabilitySource).toContain("from 'streamdown'"); - expect(richMarkdownCapabilitySource).toContain("from '@streamdown/mermaid'"); + expect(richMarkdownCapabilitySource).not.toContain("from '@streamdown/code'"); + expect(richMarkdownCapabilitySource).not.toContain("from '@streamdown/mermaid'"); expect(reasoningCapabilitySource).toContain("from './markdown-rendering'"); expect(messageSource).toContain("from '@/capabilities/markdown-rendering'"); diff --git a/src/client/components/ai-elements/prompt-input.tsx b/src/client/components/ai-elements/prompt-input.tsx index cdd19574..58b64ab1 100644 --- a/src/client/components/ai-elements/prompt-input.tsx +++ b/src/client/components/ai-elements/prompt-input.tsx @@ -8,13 +8,13 @@ import type { ChangeEventHandler, ClipboardEventHandler, ComponentProps, - FormEvent, - FormEventHandler, HTMLAttributes, KeyboardEventHandler, PropsWithChildren, ReactNode, RefObject, + SubmitEvent, + SubmitEventHandler, } from 'react'; import { Children, @@ -442,7 +442,7 @@ export type PromptInputProps = Omit, 'onSubmit' // bytes maxFileSize?: number; onError?: (err: { code: 'max_files' | 'max_file_size' | 'accept'; message: string }) => void; - onSubmit: (message: PromptInputMessage, event: FormEvent) => void | Promise; + onSubmit: (message: PromptInputMessage, event: SubmitEvent) => void | Promise; }; export const PromptInput = ({ @@ -755,7 +755,7 @@ export const PromptInput = ({ [referencedSources, clearReferencedSources], ); - const handleSubmit: FormEventHandler = useCallback( + const handleSubmit: SubmitEventHandler = useCallback( async (event) => { event.preventDefault(); diff --git a/src/client/mutations/workspace-mutations.ts b/src/client/mutations/workspace-mutations.ts index 6da716fd..ccc31bef 100644 --- a/src/client/mutations/workspace-mutations.ts +++ b/src/client/mutations/workspace-mutations.ts @@ -1,10 +1,11 @@ import { useRouter } from '@tanstack/react-router'; import type { ProjectStateTurn } from '../../shared/api-types.js'; -import { findTurnOptionByPosition } from '../workspace/workspace-controller-core.js'; +import { formatTurnResponseText } from '../../shared/chat.js'; +import { findTurnOptionsByPositions } from '../workspace/workspace-controller-core.js'; import { postJsonMutation, useClientMutation } from './client-mutation.js'; -export function useSelectTurnOptionMutation({ +export function useSubmitTurnResponseMutation({ projectId, turn, sendMessage, @@ -14,25 +15,45 @@ export function useSelectTurnOptionMutation({ sendMessage: (message: { text: string }) => Promise | void; }) { const router = useRouter(); - const mutation = useClientMutation((variables: { turnId: number; position: number }) => - postJsonMutation<{ ok: boolean }, { position: number }>( - `/api/projects/${projectId}/turns/${variables.turnId}/select`, - { position: variables.position }, - 'Failed to save selection', - ), + const mutation = useClientMutation( + (variables: { turnId: number; positions?: number[]; freeText?: string }) => + postJsonMutation<{ ok: boolean }, { positions?: number[]; freeText?: string }>( + `/api/projects/${projectId}/turns/${variables.turnId}/select`, + { + ...(variables.positions?.length ? { positions: variables.positions } : {}), + ...(variables.freeText ? { freeText: variables.freeText } : {}), + }, + 'Failed to save response', + ), ); return { - selectOption: async (position: number) => { - const selected = findTurnOptionByPosition(turn, position); - if (!selected || !turn) { + submitTurnResponse: async (positions: number[] = [], freeText?: string) => { + if (!turn) { + return; + } + const uniquePositions = [...new Set(positions)]; + const selectedOptions = findTurnOptionsByPositions(turn, uniquePositions); + if (selectedOptions.length !== uniquePositions.length) { + return; + } + const trimmedFreeText = freeText?.trim(); + const responseText = formatTurnResponseText({ + selectedOptionContents: selectedOptions.map((option) => option.content), + freeText: trimmedFreeText, + }); + if (!responseText) { return; } try { - await mutation.run({ turnId: turn.id, position }); + await mutation.run({ + turnId: turn.id, + positions: uniquePositions.length > 0 ? uniquePositions : undefined, + freeText: trimmedFreeText || undefined, + }); await router.invalidate(); - await sendMessage({ text: selected.content }); + await sendMessage({ text: responseText }); } catch { // The shared mutation hook surfaces the failure state in the UI. } diff --git a/src/client/routes/InterviewWorkspace.test.tsx b/src/client/routes/InterviewWorkspace.test.tsx index 04d6565c..233c6040 100644 --- a/src/client/routes/InterviewWorkspace.test.tsx +++ b/src/client/routes/InterviewWorkspace.test.tsx @@ -289,8 +289,8 @@ describe('InterviewWorkspace', () => { await waitFor(() => { expect(screen.getByText('Which platform should we target next?')).toBeTruthy(); - expect(screen.getByRole('button', { name: /web recommended/i })).toBeTruthy(); - expect(screen.getByRole('button', { name: /desktop/i })).toBeTruthy(); + expect(screen.getByRole('checkbox', { name: /web/i })).toBeTruthy(); + expect(screen.getByRole('checkbox', { name: /desktop/i })).toBeTruthy(); expect(screen.queryByLabelText('Type a message...')).toBeNull(); expect(routerInvalidate).not.toHaveBeenCalled(); }); @@ -455,7 +455,7 @@ describe('InterviewWorkspace', () => { }); }); - it('posts option selections, refreshes project state, and forwards the selected text back into chat', async () => { + it('posts single-option turn responses with optional free-text and forwards a combined summary into chat', async () => { currentLoaderData = createWorkspaceLoaderData({ options: [ { id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false }, @@ -472,7 +472,96 @@ describe('InterviewWorkspace', () => { renderWorkspace(); - fireEvent.click(await screen.findByRole('button', { name: /desktop/i })); + fireEvent.change(await screen.findByLabelText('Additional response context'), { + target: { value: 'Best fit for our launch' }, + }); + + fireEvent.click(await screen.findByRole('checkbox', { name: /desktop/i })); + fireEvent.click(await screen.findByRole('button', { name: /submit selected response/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/projects/1/turns/1/select', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ positions: [1], freeText: 'Best fit for our launch' }), + }), + ); + }); + + await waitFor(() => { + expect(routerInvalidate).toHaveBeenCalledTimes(1); + expect(useChatHarness.sendMessage).toHaveBeenCalledWith({ text: 'Desktop — Best fit for our launch' }); + }); + }); + + it('posts many-selection turn responses and forwards a grouped summary into chat', async () => { + currentLoaderData = createWorkspaceLoaderData({ + options: [ + { id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false }, + { id: 12, position: 1, content: 'Desktop', is_recommended: false, is_selected: false }, + { id: 13, position: 2, content: 'Mobile', is_recommended: false, is_selected: false }, + ], + }); + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + renderWorkspace(); + + fireEvent.click(await screen.findByRole('checkbox', { name: /web/i })); + fireEvent.click(await screen.findByRole('checkbox', { name: /desktop/i })); + fireEvent.change(await screen.findByLabelText('Additional response context'), { + target: { value: 'Covers both launch paths' }, + }); + fireEvent.click(await screen.findByRole('button', { name: /submit selected response/i })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/projects/1/turns/1/select', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ positions: [0, 1], freeText: 'Covers both launch paths' }), + }), + ); + }); + + await waitFor(() => { + expect(routerInvalidate).toHaveBeenCalledTimes(1); + expect(useChatHarness.sendMessage).toHaveBeenCalledWith({ + text: 'Web, Desktop — Covers both launch paths', + }); + }); + }); + + it('posts free-text-only turn responses and forwards the text into chat', async () => { + currentLoaderData = createWorkspaceLoaderData({ + options: [ + { id: 11, position: 0, content: 'Web', is_recommended: true, is_selected: false }, + { id: 12, position: 1, content: 'Desktop', is_recommended: false, is_selected: false }, + ], + }); + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + renderWorkspace(); + + fireEvent.change(await screen.findByLabelText('Additional response context'), { + target: { value: 'None of these fit our use case' }, + }); + + fireEvent.click(await screen.findByRole('button', { name: /submit free-text response/i })); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( @@ -480,14 +569,14 @@ describe('InterviewWorkspace', () => { expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ position: 1 }), + body: JSON.stringify({ freeText: 'None of these fit our use case' }), }), ); }); await waitFor(() => { expect(routerInvalidate).toHaveBeenCalledTimes(1); - expect(useChatHarness.sendMessage).toHaveBeenCalledWith({ text: 'Desktop' }); + expect(useChatHarness.sendMessage).toHaveBeenCalledWith({ text: 'None of these fit our use case' }); }); }); @@ -508,7 +597,8 @@ describe('InterviewWorkspace', () => { renderWorkspace(); - fireEvent.click(await screen.findByRole('button', { name: /desktop/i })); + fireEvent.click(await screen.findByRole('checkbox', { name: /desktop/i })); + fireEvent.click(await screen.findByRole('button', { name: /submit selected response/i })); expect((await screen.findByRole('alert')).textContent).toContain('Selection could not be saved'); expect(routerInvalidate).not.toHaveBeenCalled(); diff --git a/src/client/routes/InterviewWorkspace.tsx b/src/client/routes/InterviewWorkspace.tsx index 6d67ebfa..860781f5 100644 --- a/src/client/routes/InterviewWorkspace.tsx +++ b/src/client/routes/InterviewWorkspace.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router'; +import { useState } from 'react'; import { Conversation, @@ -31,15 +32,30 @@ const impactStyles: Record = { function TurnCard({ turn, - onSelect, + onSubmitResponse, disabled, }: { turn: ProjectStateTurn; - onSelect: (position: number) => void | Promise; + onSubmitResponse: (positions: number[], freeText?: string) => void | Promise; disabled: boolean; }) { const options = turn.options ?? []; - const hasSelection = options.some((o) => o.is_selected); + const persistedSelections = options.filter((option) => option.is_selected).map((option) => option.position); + const [selectedPositions, setSelectedPositions] = useState(persistedSelections); + const [freeText, setFreeText] = useState(''); + const hasSelection = selectedPositions.length > 0; + const hasFreeText = freeText.trim().length > 0; + const hasPersistedSelection = persistedSelections.length > 0; + + function toggleSelection(position: number) { + if (disabled || hasPersistedSelection) { + return; + } + + setSelectedPositions((current) => + current.includes(position) ? current.filter((value) => value !== position) : [...current, position], + ); + } return (
@@ -58,31 +74,80 @@ function TurnCard({ )} +
+ +