From 2d1024dd33f710b25f2def791e2911caecd7a4c1 Mon Sep 17 00:00:00 2001
From: coreci
Date: Sun, 27 Sep 2026 04:50:24 +0000
Subject: [PATCH 1/4] =?UTF-8?q?docs(P07):=202nd=20owner=20gate=20feedback?=
=?UTF-8?q?=20=E2=80=94=20ask=20intelligence=20phase=20(REQ-67..68)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
phase: 7
milestone: v0.1
status: execute
escalation:
type: prototype_approval
hook: prototype_approval
resolution: binding_extension
message: >-
Owner returned 2nd gate feedback: the New Research view must
surface follow-up query suggestions from unanswered questions,
and offer a refine option that crafts a better research query
through back-and-forth dialogue. Feedback becomes binding
requirements REQ-67/68 as execution phase 7 (+ P8 final
re-review); the milestone merge to main stays halted until the
extended prototype is approved at the REQ-62 gate.
requirements:
covered: []
partial: [REQ-67, REQ-68, REQ-62]
---/ci---
ROADMAP: P6 marked shipped-superseded; P7 (ask suggestions + refine
dialogue) + P8 (final re-review) inserted; releases table extended
(v0.1.7, v0.1.8). REQUIREMENTS: Ask Intelligence Extension section
(REQ-67/68); traceability updated. PLAN.md: phase-7 waves with
MVP/UX sections. Checkpoint: phase 7 execute.
---
.ciagent/CHECKPOINT.json | 8 +++---
.ciagent/PLAN.md | 62 ++++++++++++++++++----------------------
.ciagent/REQUIREMENTS.md | 7 ++++-
.ciagent/ROADMAP.md | 24 +++++++++++++---
4 files changed, 58 insertions(+), 43 deletions(-)
diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json
index 7b400fd..14018a5 100644
--- a/.ciagent/CHECKPOINT.json
+++ b/.ciagent/CHECKPOINT.json
@@ -1,8 +1,8 @@
{
- "phase": 6,
- "stage": "approval_pending",
+ "phase": 7,
+ "stage": "execute",
"milestone": "v0.1",
- "phase_role": "final",
+ "phase_role": "execution",
"attempts": 0,
- "updated_at": "2026-09-27T04:40:00Z"
+ "updated_at": "2026-09-27T05:10:00Z"
}
\ No newline at end of file
diff --git a/.ciagent/PLAN.md b/.ciagent/PLAN.md
index 27bb1aa..a0ce778 100644
--- a/.ciagent/PLAN.md
+++ b/.ciagent/PLAN.md
@@ -1,54 +1,48 @@
-# PLAN — Milestone v0.1 Phase 6 (FINAL): Review + Approval Re-presentation + Ship
+# PLAN — Milestone v0.1 Phase 7: Ask Screen Intelligence — Suggestions + Refine
---
-phase: 6
+phase: 7
milestone: v0.1
-status: executed
+status: planned
must_haves:
- - "Full-milestone review: PASS-WITH-FIXES — deep-link P0 (stored-report cards resolved to the wrong run) + 5 P1s fixed and verified in-phase; 75/75 tests"
- - "Audit: PASS-WITH-NOTES — reconstruction, tags, file/branch/commit discipline, secrets sweep all green; notes applied (version bump, ROADMAP status, ARCHITECTURE surfaces)"
- - "Final phase patch v0.1.6 shipped — milestone release candidate, presented at the REQ-62 owner approval gate"
+ - "Follow-up query suggestions on the New Research view, derived from unanswered questions (open task-inbox items joined with source reports); click fills the question box — editable, never auto-starts (REQ-67)"
+ - "Refine option: opt-in back-and-forth dialogue in an inline drawer (scripted clarifying questions → answers → composed refined question + suggested context); accept fills the form, or keep original (REQ-68)"
+ - "Both features opt-in — zero required clicks added to the ≤3-click happy path (pinned by test)"
+ - "All new data access extends the ApiClient boundary + MockApiClient fixtures (REQ-53/54 discipline)"
personas:
+ - frontend-engineer
- ci-lead-developer
-requirements: [REQ-62]
+requirements: [REQ-67, REQ-68]
---
## User-Facing Surface
-The extended prototype presented for owner approval: the full 5-screen click-path (Ask → Choose → Plan & Run → Reading Room → Library) now inside the persistent app shell (sidebar + top bar + avatar), the folder-structure research library with cross-report connections, the settings surface (API keys, storage, methodology defaults, appearance, about), and the ⌘K navigation palette. Releases v0.1.0–v0.1.5; v0.1.6 is the extended-prototype release candidate.
+1. **Ask suggestions** (`/ask`, below the primary form): "Pick up an open question" — cards for each open task-inbox item (question text + source report title + "from your research" attribution). Click fills the question textarea (editable, no auto-submit, focus moves to the box). Honest empty state: "No open questions yet — they collect here as you run research."
+2. **Refine drawer** (`/ask`, inline): "Refine question" button (disabled until a draft exists) opens the established dials-drawer pattern — one clarifying question at a time with answer input, progress indicator (1/2/3), then the composed refined question + suggested context preview. **Use this** fills question + context and closes; **Keep original** dismisses; **Restart** re-runs the exchange. Escape closes. Honest caption: "Scripted refinement in the prototype — model-driven refine lands in v0.2."
-## Happy Path (owner review script)
+## Happy Path
-1. `cd frontend && pnpm install && pnpm dev` → http://localhost:3000 → shell renders on /ask
-2. Ask → question → Research this (click 1)
-3. Choose → Deep Report card → Start research (click 2)
-4. Plan & Run → Approve plan (click 3) → live trace → pause/resume/cancel
-5. Read the report → hover [n] → exact quote; ledger; export Markdown/JSON
-6. Sidebar → Library → folder tree → open a stored report → matches its card title
-7. Library → Connections → shared-source ⇄ and related-topic → edges
-8. ⌘K → type "lith" → jump to the stored report
-9. Avatar → user menu → Settings → API keys masked, save round-trips
-10. Owner decision: approve → v0.1.6 published + milestone merge to main; or request changes
-
-## What this phase did
-
-1. **Review** (multi-persona, full milestone diff `main..HEAD`, P5-weighted): verdict PASS-WITH-FIXES. P0-1: stored-report deep links resolved to the wrong run (every library card + palette entry opened the demo report under a mismatched id) — fixed via the `#storedRun` resolver + card-title override in the scripted builders; probe + boundary tests prove run/report/ledger/export agree. P1s fixed in-phase: avatar-menu dismiss race (toggleRef guard, pinned by a new test), lost v2-preview pills on graph/inbox panels, stale root README, settings stale-after-save + non-live dial preview (now previews the live selection with formatted values), doc rot (run-page header, VERSIONING.md table). Suite 72 → 75.
-2. **Audit**: PASS-WITH-NOTES — tags v0.1.0–v0.1.5 verified annotated + on-branch and mapped to ship records; REQ traceability consistent with ---ci--- blocks; secrets clean across full history; branch hygiene exact. Notes applied in this phase: settings About bumped to v0.1.6 (P1-watch), ROADMAP P6 → in_progress, ARCHITECTURE.md §7 + Build Order extended with P5/P6 surfaces, release README example bumped; `---/ci---` closer convention restored going forward.
-3. **Ship (this commit sequence)**: PR #6 (phase/06 → milestone), squash-merge, tag v0.1.6, release with full milestone summary. Per REQ-62 and the prototype_approval escalation hook, the milestone merge to main does NOT proceed autonomously — the pipeline halts at the gate for the recorded owner decision.
+1. `cd frontend && pnpm install && pnpm dev` → http://localhost:3000/ask (shell present)
+2. Default path unchanged: question → Research this (click 1) → Choose → Start research (click 2) → Approve plan (click 3)
+3. Suggestions: scroll to "Pick up an open question" → click a card → question box fills with the open question → edit if desired → Research this as usual
+4. Refine: type a draft → "Refine question" → answer the 3 clarifying questions → review the composed question + suggested context → "Use this" → both boxes filled → Research this
+5. Keep-original: open the drawer, answer partway, Escape → the original draft is untouched
## UX Acceptance Criteria
-- Owner demo script runs clean on the shipped code (deep-link P0 fix verified by test)
-- No placeholder/stub text in owner-visible surfaces; preview pills honest
-- The 3-click criterion still pinned green post-shell
-- Avatar menu opens/closes cleanly; keyboard accessible throughout
+- ≤3-click default happy path still green (both features opt-in; zero required interactions)
+- Suggestions never compete with the primary action (below the form, not modal, no auto-start)
+- Refine drawer: draft visible behind, Escape-safe, honest scripted-prototype caption
+- Keyboard accessible; focus ring in accent (global standard)
+- All data via `api.*` — components never import MockApiClient; v0.2 OpenAPI swap unaffected
-## Verification
+## Waves
-- Gates: typecheck ✓ lint ✓ tests 75/75 ✓ build ✓ (re-run after review fixes, plus by audit)
-- Deep-link boundary test: every stored report resolves to its own id/question/methodology/title
-- Post-hoc v0.2 log: demo-run fallback 404 semantics, shared scripted report body disclosure, palette scroll-into-view, raw methodology slugs on cards, pt-[14vh] token
+1. **T-7.1 Boundary**: types (`SuggestedQuery`, `RefinementQuestion`, `RefinedQuestion`) → ApiClient gains `getSuggestedQueries` (mock: open `taskInboxFixture` items joined with `reportLibraryFixture`; v0.2: GET /api/suggestions), `getRefinementQuestions(draft)` (mock: scripted 3-question set; v0.2: POST /api/refine step 1), `composeRefinedQuestion(draft, answers)` (mock: deterministic composer; v0.2: model-driven) → co-located boundary tests
+2. **T-7.2 Suggestions UI**: `AskSuggestions` component on /ask; click-fills-box contract; empty state; tests
+3. **T-7.3 Refine drawer**: `RefineQuestionButton` + drawer component; exchange state machine; use/keep/restart; tests
+4. **T-7.4 Regression + polish**: 3-click re-pin, README, typography pass, gates green
## Scope Guard
-Milestone merge to main, v0.1.6 publication, v0.2 unblocking, and all v0.2 P1 work wait on the owner approval gate (REQ-62, prototype_approval escalation hook). The pipeline does not proceed autonomously past this commit.
\ No newline at end of file
+P8 (final review + approval re-presentation) follows P7 ship + UAT deploy. Milestone merge to main remains gated on the owner's recorded approval (REQ-62, prototype_approval escalation hook). No backend work, no v0.2 scope, no model calls (scripted refine only — the model-driven version is v0.2).
\ No newline at end of file
diff --git a/.ciagent/REQUIREMENTS.md b/.ciagent/REQUIREMENTS.md
index 1f99df9..212db5d 100644
--- a/.ciagent/REQUIREMENTS.md
+++ b/.ciagent/REQUIREMENTS.md
@@ -25,6 +25,10 @@
- **REQ-65**: Settings surface behind the ApiClient boundary: API keys (model/search, masked), storage endpoint (local directory), methodology defaults (resolved dial display), appearance slot, about/version
- **REQ-66**: Avatar button (top bar) opening a user menu: local single-user profile (no auth), links to settings and about; future slots visible but honest (preview-marked)
+### Ask Intelligence Extension (P7 — owner gate feedback, directed 2026-09-27)
+- **REQ-67**: New Research view surfaces follow-up query suggestions derived from unanswered questions (open task-inbox items from past runs); one click loads a suggestion into the question box for editing — never auto-starts a run
+- **REQ-68**: Refine option on the question: an opt-in back-and-forth dialogue (clarifying questions → answers → composed refined question + suggested context); accepting fills the form, or the original is kept
+
## MVP (Milestone v0.2 — core loop, gated on prototype approval)
### Methodology Framework
@@ -115,8 +119,9 @@
| REQ-57 (Plan & Run) | v0.1 | P2 | complete |
| REQ-58, REQ-59 (Reading Room, exports) | v0.1 | P2 | complete |
| REQ-60, REQ-61 (v2 mockups, 3-click) | v0.1 | P3 | complete |
-| REQ-62 (approval gate) | v0.1 | P6 | in_progress (awaiting owner decision on extended prototype) |
+| REQ-62 (approval gate) | v0.1 | P8 | in_progress (awaiting owner decision on extended prototype) |
| REQ-63, REQ-64, REQ-65, REQ-66 (app shell, library, settings, avatar) | v0.1 | P5 | complete |
+| REQ-67, REQ-68 (ask suggestions, refine dialogue) | v0.1 | P7 | in_progress |
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | blocked |
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | blocked |
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | blocked |
diff --git a/.ciagent/ROADMAP.md b/.ciagent/ROADMAP.md
index 5d60989..6fbcc47 100644
--- a/.ciagent/ROADMAP.md
+++ b/.ciagent/ROADMAP.md
@@ -47,12 +47,26 @@ Two milestones, owner-directed 2026-09-27. **Milestone v0.1 — Prototype**: the
- **Depends on**: 4
- **Status**: complete (PR #5 squash-merged to milestone/v0.1-prototype, tag v0.1.5, release created; verify PASS with one P1 fixed in-phase — main landmark restored)
-### Phase 6 — Final Review + Approval Re-presentation + Ship (FINAL)
+### Phase 6 — Final Review + Approval Re-presentation + Ship (shipped v0.1.6 — superseded by 2nd extension)
- **Description**: Multi-persona review of the full milestone diff (now including P5), audit, then re-present the **extended prototype** at the REQ-62 owner approval gate. On recorded approval: milestone merge to main, tag v0.1.6 — the milestone release. (v0.2 P1 branches only after this tag.)
- **Requirements covered**: REQ-62 (gate) + milestone approval of REQ-52..66
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.1.6 tagged + release; pipeline halts at the approval gate for the owner's recorded decision
- **Depends on**: 5
-- **Status**: in_progress (review PASS-WITH-FIXES — deep-link P0 + 5 P1s fixed; audit PASS-WITH-NOTES; shipping as v0.1.6, then the gate halts for the owner decision)
+- **Status**: shipped (PR #6 squash-merged, tag v0.1.6 + release created; review PASS-WITH-FIXES — deep-link P0 + 5 P1s fixed; audit PASS; owner directed UAT deploy then returned 2nd gate feedback → binding extension cycle P7/P8; milestone merge to main stays halted)
+
+### Phase 7 — Ask Screen Intelligence: Suggestions + Refine (2nd owner gate feedback)
+- **Description**: New Research view gains follow-up query suggestions derived from unanswered questions (open task-inbox items joined with their source reports); one click fills the question box — editable, never auto-starts. An opt-in Refine dialogue (inline drawer, one clarifying question at a time, scripted in the prototype) composes a refined question + suggested context; accept fills the form, or keep the original. Both are opt-in affordances — zero required clicks added to the ≤3-click happy path. All data through the ApiClient boundary.
+- **Requirements covered**: REQ-67, REQ-68
+- **Success criteria**: suggestions render from open inbox items with source attribution; click fills the box (no auto-submit); refine drawer completes a scripted exchange and composes a refined question; both flows keyboard-accessible; 3-click path pinned green; gates green
+- **Depends on**: 6
+- **Status**: in_progress
+
+### Phase 8 — Final Review + Approval Re-presentation + Ship (FINAL)
+- **Description**: Multi-persona review of the full milestone diff (now including P7), audit, then re-present the extended prototype at the REQ-62 owner approval gate. On recorded approval: milestone merge to main, tag v0.1.8 — the milestone release. (v0.2 P1 branches only after this tag.)
+- **Requirements covered**: REQ-62 (gate) + milestone approval of REQ-52..68
+- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.1.8 tagged + release; UAT staged per owner directive; pipeline halts at the approval gate
+- **Depends on**: 7
+- **Status**: planned
## Milestone v0.2 — MVP (feature) — BLOCKED until prototype approval
@@ -87,9 +101,11 @@ Phases renumbered from the original v1.0 plan (1-based within the milestone):
- **Status**: blocked
## Milestone Releases
-- **v0.1.4 = extended-prototype candidate** (feature milestone; final phase patch — superseded by the P5/P6 extension cycle)
+- **v0.1.4 = extended-prototype candidate** (superseded by the P5/P6 extension cycle)
- **v0.1.5 = app shell + research library + settings + avatar** (P5 patch)
-- **v0.1.6 = milestone release** (P6 final phase patch — the approved extended prototype release, pending owner decision)
+- **v0.1.6 = extended-prototype candidate** (superseded by the P7/P8 extension cycle)
+- **v0.1.7 = ask suggestions + refine dialogue** (P7 patch)
+- **v0.1.8 = milestone release** (P8 final phase patch — the approved extended prototype release, pending owner decision)
- **v0.2.x = MVP release** (MAJOR still 0; ship only if all v0.2 phases complete)
- If MVP scope extends: additional 0.x minor milestones (v0.3, …), each a full milestone cycle
- **v1.0.0**: owner-approved release only — not created by the pipeline autonomously
--
2.43.0
From 6e57b17dbf8fa8e30f35976a78a43d8ea677be2d Mon Sep 17 00:00:00 2001
From: coreci
Date: Sun, 27 Sep 2026 04:51:36 +0000
Subject: [PATCH 2/4] =?UTF-8?q?feat(P07):=20ask-intelligence=20ApiClient?=
=?UTF-8?q?=20boundary=20=E2=80=94=20suggestions=20+=20refine=20(T-7.1)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
phase: 7
milestone: v0.1
status: execute
persona: frontend-engineer
task: T-7.1
requirements:
covered: []
partial: [REQ-67, REQ-68]
---/ci---
Types: SuggestedQuery (question + source run/report attribution),
RefinementQuestion (prompt/placeholder/optional), RefinedQuestion
(question + context). ApiClient gains getSuggestedQueries (v0.2:
GET /api/suggestions), getRefinementQuestions (v0.2: POST
/api/refine/questions), composeRefinedQuestion (v0.2: POST
/api/refine/compose — model-driven there, deterministic here).
Mock: suggestions derive live from OPEN taskInboxFixture items
joined with reportLibraryFixture for titles — done items drop
automatically; refine exchange is a scripted 3-question set
(scope/decision/depth) with a composer that weaves the scope into
the question and the optional answers into context, degrading
gracefully on skips. 6 boundary tests; gates green (81/81).
---
frontend/src/lib/api/client.ts | 29 +++++
.../src/lib/api/mock/ask-boundary.test.ts | 85 +++++++++++++++
frontend/src/lib/api/mock/ask-fixtures.ts | 100 ++++++++++++++++++
frontend/src/lib/api/mock/mock-client.ts | 30 ++++++
frontend/src/lib/types.ts | 37 +++++++
5 files changed, 281 insertions(+)
create mode 100644 frontend/src/lib/api/mock/ask-boundary.test.ts
create mode 100644 frontend/src/lib/api/mock/ask-fixtures.ts
diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts
index 2edffff..1f6249d 100644
--- a/frontend/src/lib/api/client.ts
+++ b/frontend/src/lib/api/client.ts
@@ -14,11 +14,14 @@ import type {
InboxItem,
Methodology,
ProjectGraph,
+ RefinementQuestion,
+ RefinedQuestion,
Report,
ReportConnection,
ReportLibrary,
ResearchRun,
Settings,
+ SuggestedQuery,
TraceEvent,
UserProfile,
} from "@/lib/types";
@@ -35,6 +38,8 @@ export type {
InboxItem,
Methodology,
ProjectGraph,
+ RefinementQuestion,
+ RefinedQuestion,
ReportConnection,
ReportConnectionKind,
ReportFolder,
@@ -45,6 +50,7 @@ export type {
RunPlan,
RunStatus,
Settings,
+ SuggestedQuery,
Task,
TraceEvent,
UserProfile,
@@ -148,6 +154,29 @@ export interface ApiClient {
* responses — masked values only.
*/
saveSettings(update: Partial): Promise;
+
+ /**
+ * Follow-up query suggestions for the New Research view —
+ * derived from unanswered open questions of past runs
+ * (maps to GET /api/suggestions in v0.2 — REQ-67).
+ */
+ getSuggestedQueries(): Promise;
+
+ /**
+ * The clarifying questions for the refine dialogue, keyed to the
+ * draft (maps to POST /api/refine/questions in v0.2 — REQ-68).
+ */
+ getRefinementQuestions(draft: string): Promise;
+
+ /**
+ * Compose the refined question + suggested context from the
+ * exchange answers (maps to POST /api/refine/compose in v0.2,
+ * model-driven there; deterministic in the prototype — REQ-68).
+ */
+ composeRefinedQuestion(
+ draft: string,
+ answers: Record,
+ ): Promise;
}
/**
diff --git a/frontend/src/lib/api/mock/ask-boundary.test.ts b/frontend/src/lib/api/mock/ask-boundary.test.ts
new file mode 100644
index 0000000..c91b79d
--- /dev/null
+++ b/frontend/src/lib/api/mock/ask-boundary.test.ts
@@ -0,0 +1,85 @@
+/**
+ * Boundary tests for the P7 ask-intelligence surface (T-7.1, REQ-67, REQ-68).
+ *
+ * Suggestions derive from OPEN inbox items with source attribution;
+ * done items drop out. The refine dialogue returns its scripted
+ * question set and composes deterministically — skipped optional
+ * answers degrade gracefully, the non-optional scope always weaves in.
+ */
+
+import { describe, expect, it } from "vitest";
+import { api } from "@/lib/api/client";
+import type { ApiClient } from "@/lib/api/client";
+import { taskInboxFixture } from "@/lib/api/mock/library-fixtures";
+
+const client: ApiClient = api;
+
+describe("getSuggestedQueries (REQ-67)", () => {
+ it("derives suggestions from open inbox items only", async () => {
+ const suggestions = await client.getSuggestedQueries();
+ const openItems = taskInboxFixture.filter((i) => i.status !== "done");
+ expect(suggestions).toHaveLength(openItems.length);
+ const openQuestions = new Set(openItems.map((i) => i.question));
+ for (const suggestion of suggestions) {
+ expect(openQuestions).toContain(suggestion.question);
+ }
+ });
+
+ it("attributes each suggestion to its source report", async () => {
+ const suggestions = await client.getSuggestedQueries();
+ expect(suggestions.length).toBeGreaterThan(0);
+ for (const suggestion of suggestions) {
+ expect(suggestion.sourceRunId).toBeTruthy();
+ expect(suggestion.sourceReportTitle.length).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe("getRefinementQuestions (REQ-68)", () => {
+ it("returns the scripted 3-question exchange", async () => {
+ const questions = await client.getRefinementQuestions("How do batteries work?");
+ expect(questions).toHaveLength(3);
+ const first = questions[0];
+ if (first === undefined) throw new Error("no refinement questions");
+ expect(first.prompt.length).toBeGreaterThan(0);
+ expect(first.placeholder.length).toBeGreaterThan(0);
+ // The scope question is required; the rest are skippable.
+ expect(questions.filter((q) => !q.optional).map((q) => q.id)).toEqual([
+ "scope",
+ ]);
+ });
+});
+
+describe("composeRefinedQuestion (REQ-68)", () => {
+ const DRAFT = "What are the latest advances in solid-state batteries";
+
+ it("weaves the scope into the question and answers into context", async () => {
+ const refined = await client.composeRefinedQuestion(DRAFT, {
+ scope: "automotive applications",
+ decision: "choosing a supplier for a 2027 EV program",
+ depth: "an executive summary with verifiable evidence",
+ });
+ expect(refined.question).toBe(
+ "What are the latest advances in solid-state batteries — focusing on automotive applications",
+ );
+ expect(refined.context).toContain("choosing a supplier for a 2027 EV program");
+ expect(refined.context).toContain(
+ "an executive summary with verifiable evidence",
+ );
+ });
+
+ it("degrades gracefully when optional answers are skipped", async () => {
+ const refined = await client.composeRefinedQuestion(DRAFT, {
+ scope: "grid storage",
+ });
+ expect(refined.question).toBe(`${DRAFT} — focusing on grid storage`);
+ expect(refined.context).not.toContain("Feeds this decision");
+ expect(refined.context).not.toContain("Depth wanted");
+ });
+
+ it("keeps the draft intact when no answers are given", async () => {
+ const refined = await client.composeRefinedQuestion(DRAFT, {});
+ expect(refined.question).toBe(DRAFT);
+ expect(refined.context).toContain("Refined through the question dialogue");
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/api/mock/ask-fixtures.ts b/frontend/src/lib/api/mock/ask-fixtures.ts
new file mode 100644
index 0000000..9394c2e
--- /dev/null
+++ b/frontend/src/lib/api/mock/ask-fixtures.ts
@@ -0,0 +1,100 @@
+/**
+ * Ask-intelligence fixtures (T-7.1, REQ-67, REQ-68).
+ *
+ * Suggested queries derive from OPEN task-inbox items joined with
+ * their source reports (the unanswered-questions contract). The
+ * refine dialogue is a scripted 3-question exchange with a
+ * deterministic composer — the model-driven version is v0.2.
+ */
+
+import type {
+ RefinementQuestion,
+ RefinedQuestion,
+ SuggestedQuery,
+} from "@/lib/types";
+import { reportLibraryFixture } from "@/lib/api/mock/shell-fixtures";
+import { taskInboxFixture } from "@/lib/api/mock/library-fixtures";
+
+/* ------------------------------------------------------------------ */
+/* Suggested queries (REQ-67) */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Build suggestions from open inbox items: each unanswered question
+ * becomes a suggested query, attributed to its source report.
+ */
+export function buildSuggestedQueries(): SuggestedQuery[] {
+ const titleByRunId = new Map(
+ reportLibraryFixture.reports.map((r) => [r.runId, r.title] as const),
+ );
+ return taskInboxFixture
+ .filter((item) => item.status !== "done")
+ .map((item) => ({
+ id: `sug-${item.id}`,
+ question: item.question,
+ sourceRunId: item.sourceRunId,
+ sourceReportTitle: titleByRunId.get(item.sourceRunId) ?? "a past run",
+ }));
+}
+
+/* ------------------------------------------------------------------ */
+/* Refine dialogue (REQ-68) — scripted in the prototype */
+/* ------------------------------------------------------------------ */
+
+/**
+ * The scripted clarifying questions. Generic enough to apply to any
+ * research draft: scope, decision, and depth — the three answers a
+ * good research question bakes in.
+ */
+export const refinementQuestionsFixture: RefinementQuestion[] = [
+ {
+ id: "scope",
+ prompt: "What specific part of this topic matters most to you?",
+ placeholder: "e.g. just automotive applications, not consumer devices",
+ optional: false,
+ },
+ {
+ id: "decision",
+ prompt: "What decision or action will this research inform?",
+ placeholder: "e.g. choosing a battery supplier for a 2027 EV program",
+ optional: true,
+ },
+ {
+ id: "depth",
+ prompt: "How deep should the answer go — a quick memo or a full report?",
+ placeholder: "e.g. executive summary with evidence I can verify",
+ optional: true,
+ },
+];
+
+/**
+ * Deterministic composer: weave the answers into the draft question
+ * plus a suggested context block. Answers are optional-terminated so
+ * a half-answered exchange still composes (skipped questions drop).
+ */
+export function composeRefinedQuestion(
+ draft: string,
+ answers: Record,
+): RefinedQuestion {
+ const scope = answers.scope?.trim();
+ const decision = answers.decision?.trim();
+ const depth = answers.depth?.trim();
+
+ // Question: draft + the specific scope (the one non-optional dial).
+ const question =
+ scope && scope.length > 0
+ ? `${draft.trim()} — focusing on ${scope}`
+ : draft.trim();
+
+ // Context: the remaining answers, assembled as guidance lines.
+ const contextLines: string[] = [];
+ if (decision && decision.length > 0) {
+ contextLines.push(`Feeds this decision: ${decision}.`);
+ }
+ if (depth && depth.length > 0) {
+ contextLines.push(`Depth wanted: ${depth}.`);
+ }
+ contextLines.push("Refined through the question dialogue.");
+
+ return { question, context: contextLines.join(" ") };
+}
\ No newline at end of file
diff --git a/frontend/src/lib/api/mock/mock-client.ts b/frontend/src/lib/api/mock/mock-client.ts
index a19312a..434057c 100644
--- a/frontend/src/lib/api/mock/mock-client.ts
+++ b/frontend/src/lib/api/mock/mock-client.ts
@@ -7,6 +7,11 @@
*/
import type { ApiClient, CreateRunInput } from "@/lib/api/client";
+import {
+ buildSuggestedQueries,
+ composeRefinedQuestion,
+ refinementQuestionsFixture,
+} from "@/lib/api/mock/ask-fixtures";
import { methodologyFixtures, findMethodologyBySlug } from "@/lib/api/mock/fixtures";
import {
projectGraphFixture,
@@ -31,11 +36,14 @@ import type {
InboxItem,
Methodology,
ProjectGraph,
+ RefinementQuestion,
+ RefinedQuestion,
Report,
ReportConnection,
ReportLibrary,
ResearchRun,
Settings,
+ SuggestedQuery,
TraceEvent,
UserProfile,
} from "@/lib/types";
@@ -300,6 +308,28 @@ export class MockApiClient implements ApiClient {
/** Session-persisted settings (mock-local). */
#settings: Settings = structuredClone(settingsFixture);
+ /**
+ * Follow-up suggestions from unanswered open questions (REQ-67).
+ * Derived live from the inbox fixtures — a completed item drops
+ * out of suggestions automatically.
+ */
+ async getSuggestedQueries(): Promise {
+ return structuredClone(buildSuggestedQueries());
+ }
+
+ /** The scripted refine-dialogue questions (REQ-68). */
+ async getRefinementQuestions(_draft: string): Promise {
+ return structuredClone(refinementQuestionsFixture);
+ }
+
+ /** Deterministic refine composer (REQ-68; model-driven in v0.2). */
+ async composeRefinedQuestion(
+ draft: string,
+ answers: Record,
+ ): Promise {
+ return composeRefinedQuestion(draft, answers);
+ }
+
/** The scripted run for the prototype's demo question. */
#defaultRun(): ResearchRun {
return buildScriptedRun({
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index a754d38..d631c6d 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -407,3 +407,40 @@ export interface UserProfile {
tagline: string;
}
+/* ------------------------------------------------------------------ */
+/* Ask intelligence (P7 vocabulary — REQ-67, REQ-68) */
+/* ------------------------------------------------------------------ */
+
+/**
+ * A follow-up query suggestion on the New Research view — derived
+ * from an unanswered open question (task-inbox item) of a past run.
+ */
+export interface SuggestedQuery {
+ id: string;
+ /** The suggested question text — loaded into the question box. */
+ question: string;
+ /** The run the open question came from. */
+ sourceRunId: string;
+ /** Source report title for attribution ("from your research"). */
+ sourceReportTitle: string;
+}
+
+/** One clarifying question in the refine dialogue. */
+export interface RefinementQuestion {
+ id: string;
+ /** The clarifying question shown to the user. */
+ prompt: string;
+ /** Input hint for the answer box. */
+ placeholder: string;
+ /** True when an answer may be skipped (the composer tolerates it). */
+ optional: boolean;
+}
+
+/** The composed result of a completed refine exchange. */
+export interface RefinedQuestion {
+ /** The refined research question. */
+ question: string;
+ /** Suggested context assembled from the answers. */
+ context: string;
+}
+
--
2.43.0
From e2a5ad5f058a74b2d82087bf3c4be43f4ceeff68 Mon Sep 17 00:00:00 2001
From: coreci
Date: Sun, 27 Sep 2026 04:55:13 +0000
Subject: [PATCH 3/4] feat(P07): ask suggestions + refine dialogue on the New
Research view (T-7.2..T-7.4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
phase: 7
milestone: v0.1
status: execute
persona: frontend-engineer
task: T-7.2
requirements:
covered: [REQ-67, REQ-68]
partial: []
---/ci---
/ask restructured: AskView (client) owns question+context state;
AskForm retired (its tests move to AskView; the 3-click test drives
AskView — criterion green, suggestions/refine are opt-in).
REQ-67 AskSuggestions: open questions from past runs as clickable
cards (question + 'From your research — '
attribution); click fills the question box for editing, never
auto-submits; honest empty state ('they collect here as you run
research').
REQ-68 RefineQuestionButton + RefineDrawer: disabled until a draft
exists; inline drawer (dials-drawer pattern) shows the draft, asks
the scripted clarifying questions one at a time (progress n/3,
required scope vs skippable decision/depth, Enter submits), then
previews the composed refined question + suggested context; Use
this fills the form, Restart re-runs, Keep original / Escape leaves
the draft untouched; honest 'scripted in the prototype' caption.
Tests: 8 AskView suites (submit carry, suggestions render/join,
click-fills-box no-auto-submit, refine open/compose/skip/keep/
escape) + 6 boundary; 3-click re-pinned on AskView. Gates: lint
0/0, typecheck, 86/86, build. README documents both affordances.
---
frontend/README.md | 7 +
frontend/src/app/ask/page.tsx | 12 +-
frontend/src/components/ask-form.test.tsx | 82 ------
frontend/src/components/ask-form.tsx | 78 -----
frontend/src/components/ask-suggestions.tsx | 88 ++++++
frontend/src/components/ask-view.test.tsx | 222 ++++++++++++++
frontend/src/components/ask-view.tsx | 96 ++++++
frontend/src/components/refine-question.tsx | 277 ++++++++++++++++++
.../src/components/three-click-path.test.tsx | 6 +-
frontend/src/lib/api/mock/mock-client.ts | 6 +-
10 files changed, 706 insertions(+), 168 deletions(-)
delete mode 100644 frontend/src/components/ask-form.test.tsx
delete mode 100644 frontend/src/components/ask-form.tsx
create mode 100644 frontend/src/components/ask-suggestions.tsx
create mode 100644 frontend/src/components/ask-view.test.tsx
create mode 100644 frontend/src/components/ask-view.tsx
create mode 100644 frontend/src/components/refine-question.tsx
diff --git a/frontend/README.md b/frontend/README.md
index 6b2184c..b0e7ba1 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -35,6 +35,13 @@ The happy path:
The shell adds zero required clicks to that path. Everything else it
hosts is wayfinding and accumulated research:
+- **Open questions** (`/ask`, below the form) — follow-up suggestions
+ derived from unanswered questions of your past runs; one click loads
+ one into the question box for editing (never auto-starts).
+- **Refine question** (`/ask`, beside the box) — an opt-in back-and-forth:
+ a few clarifying questions, then a composed refined question with
+ suggested context. Use it, or keep your original. Scripted in the
+ prototype — model-driven refine lands with v0.2.
- **Library** (`/library`) — every report you've generated, filed into
auto-categorized folders; sort by recency or title; open any card to
re-enter its Reading Room. The **Connections** panel draws the links
diff --git a/frontend/src/app/ask/page.tsx b/frontend/src/app/ask/page.tsx
index 87e77e7..3e2a9bb 100644
--- a/frontend/src/app/ask/page.tsx
+++ b/frontend/src/app/ask/page.tsx
@@ -1,8 +1,10 @@
-import { AskForm } from "@/components/ask-form";
+import { AskView } from "@/components/ask-view";
/**
- * /ask (T-3.1, REQ-55) — the front door: one question box, one optional
- * context box, one primary action. Generous whitespace, no competition.
+ * /ask (T-3.1, REQ-55; T-7.2/7.3, REQ-67/68) — the front door: one
+ * question box, one optional context box, one primary action. The
+ * opt-in affordances live below and beside the form — open-question
+ * suggestions and the refine dialogue — never competing with it.
* The app shell (sidebar + top bar) lives in the root layout.
*/
export default function AskPage() {
@@ -21,11 +23,11 @@ export default function AskPage() {
-
+
Prototype: runs on scripted mock data — no API keys, no live search.
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/ask-form.test.tsx b/frontend/src/components/ask-form.test.tsx
deleted file mode 100644
index 42aa577..0000000
--- a/frontend/src/components/ask-form.test.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * AskForm tests (T-3.3, REQ-55).
- *
- * - Button disabled until the question is non-empty
- * - Submit stores the payload in the ask store (state carry) and
- * routes to /choose
- */
-
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { AskForm } from "@/components/ask-form";
-import { clearAskState, getAskState } from "@/lib/ask-store";
-
-const pushMock = vi.hoisted(() => vi.fn());
-
-vi.mock("next/navigation", () => ({
- useRouter: () => ({ push: pushMock }),
-}));
-
-describe("AskForm", () => {
- beforeEach(() => {
- pushMock.mockClear();
- clearAskState();
- });
-
- afterEach(cleanup);
-
- it("disables the submit button until a question is typed", async () => {
- const user = userEvent.setup();
- render();
-
- const button = screen.getByRole("button", { name: "Research this" });
- expect(button).toBeDisabled();
-
- const question = screen.getByLabelText(/what do you want to know\?/i);
- await user.type(question, "What are the latest advances in solid-state batteries?");
-
- expect(button).toBeEnabled();
- });
-
- it("submits: stores question + context and routes to /choose", async () => {
- const user = userEvent.setup();
- render();
-
- await user.type(
- screen.getByLabelText(/what do you want to know\?/i),
- "What are the latest advances in solid-state batteries?",
- );
- await user.type(
- screen.getByLabelText(/anything else it should know\?/i),
- "Focus on automotive applications",
- );
- await user.click(screen.getByRole("button", { name: "Research this" }));
-
- // State carry (UX criterion): the payload survives navigation.
- const carried = getAskState();
- expect(carried?.question).toBe(
- "What are the latest advances in solid-state batteries?",
- );
- expect(carried?.context).toBe("Focus on automotive applications");
-
- await waitFor(() => {
- expect(pushMock).toHaveBeenCalledWith("/choose");
- });
- });
-
- it("submits without storing an empty context", async () => {
- const user = userEvent.setup();
- render();
-
- await user.type(
- screen.getByLabelText(/what do you want to know\?/i),
- "Who makes solid-state cells today?",
- );
- await user.click(screen.getByRole("button", { name: "Research this" }));
-
- const carried = getAskState();
- expect(carried?.question).toBe("Who makes solid-state cells today?");
- expect(carried?.context).toBeUndefined();
- });
-});
\ No newline at end of file
diff --git a/frontend/src/components/ask-form.tsx b/frontend/src/components/ask-form.tsx
deleted file mode 100644
index bf20f44..0000000
--- a/frontend/src/components/ask-form.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-"use client";
-
-/**
- * AskForm (T-3.1, REQ-55) — the front door.
- *
- * Exactly ONE question textarea + ONE optional context textarea + one
- * primary action. Nothing else competes for attention. Submit stores
- * the payload in the ask store and routes to /choose.
- */
-
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-import type { FormEvent } from "react";
-import { setAskState } from "@/lib/ask-store";
-
-export function AskForm() {
- const router = useRouter();
- const [question, setQuestion] = useState("");
- const [context, setContext] = useState("");
- const [submitting, setSubmitting] = useState(false);
-
- const canSubmit = question.trim().length > 0 && !submitting;
-
- function handleSubmit(event: FormEvent) {
- event.preventDefault();
- if (!canSubmit) return;
- setSubmitting(true);
- setAskState({
- question: question.trim(),
- ...(context.trim() !== "" ? { context: context.trim() } : {}),
- });
- router.push("/choose");
- }
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/frontend/src/components/ask-suggestions.tsx b/frontend/src/components/ask-suggestions.tsx
new file mode 100644
index 0000000..c1d5420
--- /dev/null
+++ b/frontend/src/components/ask-suggestions.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+/**
+ * AskSuggestions (T-7.2, REQ-67) — open questions worth following up.
+ *
+ * Follow-up query suggestions derived from unanswered questions of
+ * past runs, behind the ApiClient boundary. Clicking a card fills the
+ * question box (editable — never auto-submits). Sits below the primary
+ * form action; the honest empty state explains how items collect here.
+ */
+
+import { useEffect, useState } from "react";
+import { api, type SuggestedQuery } from "@/lib/api/client";
+
+type SuggestionsState =
+ | { kind: "loading" }
+ | { kind: "error" }
+ | { kind: "ready"; suggestions: SuggestedQuery[] };
+
+export function AskSuggestions({ onPick }: { onPick: (question: string) => void }) {
+ const [state, setState] = useState({ kind: "loading" });
+
+ useEffect(() => {
+ let cancelled = false;
+ api
+ .getSuggestedQueries()
+ .then((suggestions) => {
+ if (!cancelled) setState({ kind: "ready", suggestions });
+ })
+ .catch(() => {
+ if (!cancelled) setState({ kind: "error" });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return (
+
+
+ Pick up an open question
+
+
+ {state.kind === "loading" && (
+ Checking your research…
+ )}
+ {state.kind === "error" && (
+
+ Suggestions unavailable right now.
+
+ )}
+ {state.kind === "ready" && state.suggestions.length === 0 && (
+
+ No open questions yet — they collect here as you run research.
+
+ )}
+ {state.kind === "ready" && state.suggestions.length > 0 && (
+ <>
+
+ Unanswered questions from your past runs — one click loads them
+ here for editing.
+
+
+ {state.suggestions.map((suggestion) => (
+ -
+
+
+ ))}
+
+ >
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/ask-view.test.tsx b/frontend/src/components/ask-view.test.tsx
new file mode 100644
index 0000000..5fd5d4a
--- /dev/null
+++ b/frontend/src/components/ask-view.test.tsx
@@ -0,0 +1,222 @@
+/**
+ * AskView tests (T-7.2/T-7.3, REQ-55, REQ-67, REQ-68).
+ *
+ * - Submit behavior: stores question + context, routes to /choose
+ * - Suggestions: render from open questions with attribution; a click
+ * fills the question box (editable, NO auto-submit)
+ * - Refine: disabled without a draft; the exchange completes and
+ * "Use this" fills question + context; "Keep original"/Escape leaves
+ * the draft untouched
+ */
+
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AskView } from "@/components/ask-view";
+import { clearAskState, getAskState } from "@/lib/ask-store";
+
+const pushMock = vi.hoisted(() => vi.fn());
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: pushMock }),
+}));
+
+describe("AskView", () => {
+ beforeEach(() => {
+ pushMock.mockClear();
+ clearAskState();
+ });
+
+ afterEach(cleanup);
+
+ it("submits: stores question + context and routes to /choose", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(
+ screen.getByLabelText(/what do you want to know\?/i),
+ "What are the latest advances in solid-state batteries?",
+ );
+ await user.type(
+ screen.getByLabelText(/anything else it should know\?/i),
+ "Focus on automotive applications",
+ );
+ await user.click(screen.getByRole("button", { name: "Research this" }));
+
+ const carried = getAskState();
+ expect(carried?.question).toBe(
+ "What are the latest advances in solid-state batteries?",
+ );
+ expect(carried?.context).toBe("Focus on automotive applications");
+ await waitFor(() => {
+ expect(pushMock).toHaveBeenCalledWith("/choose");
+ });
+ });
+
+ it("suggestions: open questions render with source attribution", async () => {
+ render();
+ // Open inbox items from the fixtures (3 open of 4 total).
+ expect(
+ await screen.findByText(
+ "Do sulfide electrolytes outperform oxide ceramics below room temperature?",
+ ),
+ ).toBeInTheDocument();
+ // Both run-solid-state suggestions attribute to the same source report.
+ expect(
+ screen.getAllByText(/From your research — Solid-State Battery Advances/i)
+ .length,
+ ).toBe(2);
+ // The done item never appears as a suggestion.
+ expect(
+ screen.queryByText(
+ "How do 2027 deployment targets compare across Japanese and EU consortia?",
+ ),
+ ).toBeNull();
+ });
+
+ it("suggestion click fills the question box — no auto-submit", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const card = await screen.findByRole("button", {
+ name: /Which solid-state startups have shipped pilot cells to automakers\?/i,
+ });
+ await user.click(card);
+
+ // The question box now holds the suggestion — editable.
+ const box = screen.getByLabelText(/what do you want to know\?/i);
+ expect(box).toHaveValue(
+ "Which solid-state startups have shipped pilot cells to automakers?",
+ );
+ // No navigation fired — the user stays in control.
+ expect(pushMock).not.toHaveBeenCalled();
+ });
+
+ it("refine: disabled without a draft, opens with one", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const refine = screen.getByRole("button", { name: /Refine question/i });
+ expect(refine).toBeDisabled();
+
+ await user.type(
+ screen.getByLabelText(/what do you want to know\?/i),
+ "What are the latest advances in solid-state batteries",
+ );
+ expect(refine).toBeEnabled();
+ await user.click(refine);
+ expect(
+ await screen.findByRole("region", { name: /Refine your question/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("refine: full exchange composes; Use this fills the form", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(
+ screen.getByLabelText(/what do you want to know\?/i),
+ "What are the latest advances in solid-state batteries",
+ );
+ await user.click(screen.getByRole("button", { name: /Refine question/i }));
+ await screen.findByRole("region", { name: /Refine your question/i });
+
+ // Q1 (required): scope.
+ const answer1 = await screen.findByLabelText(/What specific part of this topic matters most to you\?/i);
+ await user.type(answer1, "automotive applications");
+ await user.click(screen.getByRole("button", { name: "Next" }));
+
+ // Q2 (optional): decision.
+ const answer2 = await screen.findByLabelText(/What decision or action will this research inform\?/i);
+ await user.type(answer2, "choosing a supplier for a 2027 EV program");
+ await user.click(screen.getByRole("button", { name: "Next" }));
+
+ // Q3 (optional): depth.
+ const answer3 = await screen.findByLabelText(/How deep should the answer go/i);
+ await user.type(answer3, "an executive summary with verifiable evidence");
+ await user.click(screen.getByRole("button", { name: "Compose question" }));
+
+ // Composition preview appears.
+ expect(
+ await screen.findByText(/— focusing on automotive applications/i),
+ ).toBeInTheDocument();
+
+ // Use this → fills question + context, closes the drawer.
+ await user.click(screen.getByRole("button", { name: "Use this" }));
+ expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
+ "What are the latest advances in solid-state batteries — focusing on automotive applications",
+ );
+ const contextValue = screen.getByLabelText(
+ /anything else it should know\?/i,
+ );
+ expect(contextValue).toHaveValue(
+ "Feeds this decision: choosing a supplier for a 2027 EV program. Depth wanted: an executive summary with verifiable evidence. Refined through the question dialogue.",
+ );
+ expect(
+ screen.queryByRole("region", { name: /Refine your question/i }),
+ ).toBeNull();
+ });
+
+ it("refine: optional questions are skippable and compose still works", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(
+ screen.getByLabelText(/what do you want to know\?/i),
+ "Who makes solid-state cells today",
+ );
+ await user.click(screen.getByRole("button", { name: /Refine question/i }));
+ await screen.findByRole("region", { name: /Refine your question/i });
+
+ await user.type(
+ await screen.findByLabelText(/What specific part of this topic matters most to you\?/i),
+ "pilot production",
+ );
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await user.click(await screen.findByRole("button", { name: "Skip" }));
+ await user.click(await screen.findByRole("button", { name: "Compose question" }));
+
+ expect(
+ await screen.findByText(/— focusing on pilot production/i),
+ ).toBeInTheDocument();
+ // Skipped decision answer does not appear in the composed context.
+ expect(screen.queryByText(/Feeds this decision/i)).toBeNull();
+ });
+
+ it("refine: Keep original leaves the draft untouched", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const DRAFT = "What are the latest advances in solid-state batteries";
+ await user.type(screen.getByLabelText(/what do you want to know\?/i), DRAFT);
+ await user.click(screen.getByRole("button", { name: /Refine question/i }));
+ await screen.findByRole("region", { name: /Refine your question/i });
+
+ // Answer partway, then keep the original.
+ await user.type(
+ await screen.findByLabelText(/What specific part of this topic matters most to you\?/i),
+ "automotive applications",
+ );
+ await user.click(screen.getByRole("button", { name: "Keep original" }));
+
+ expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(DRAFT);
+ expect(
+ screen.queryByRole("region", { name: /Refine your question/i }),
+ ).toBeNull();
+ });
+
+ it("refine: Escape closes the drawer without touching the draft", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const DRAFT = "How good are solid-state batteries";
+ await user.type(screen.getByLabelText(/what do you want to know\?/i), DRAFT);
+ await user.click(screen.getByRole("button", { name: /Refine question/i }));
+ await screen.findByRole("region", { name: /Refine your question/i });
+
+ await user.keyboard("{Escape}");
+ expect(
+ screen.queryByRole("region", { name: /Refine your question/i }),
+ ).toBeNull();
+ expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(DRAFT);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/ask-view.tsx b/frontend/src/components/ask-view.tsx
new file mode 100644
index 0000000..19d76fa
--- /dev/null
+++ b/frontend/src/components/ask-view.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+/**
+ * AskView (T-7.2, T-7.3, REQ-55, REQ-67, REQ-68) — the /ask screen client.
+ *
+ * Owns the question + context state so TWO opt-in affordances can fill
+ * the form: open-question suggestions (click → box fills, editable) and
+ * the refine dialogue (inline drawer → composed question + context).
+ * Neither auto-submits — "Research this" stays the one primary action,
+ * so the ≤3-click happy path is untouched.
+ */
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import type { FormEvent } from "react";
+import { AskSuggestions } from "@/components/ask-suggestions";
+import { RefineQuestionButton } from "@/components/refine-question";
+import { setAskState } from "@/lib/ask-store";
+
+export function AskView() {
+ const router = useRouter();
+ const [question, setQuestion] = useState("");
+ const [context, setContext] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const canSubmit = question.trim().length > 0 && !submitting;
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ if (!canSubmit) return;
+ setSubmitting(true);
+ setAskState({
+ question: question.trim(),
+ ...(context.trim() !== "" ? { context: context.trim() } : {}),
+ });
+ router.push("/choose");
+ }
+
+ return (
+
+
+
+ {/* Suggestions — below the primary action, never competing (REQ-67). */}
+
setQuestion(suggested)} />
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/refine-question.tsx b/frontend/src/components/refine-question.tsx
new file mode 100644
index 0000000..77119d6
--- /dev/null
+++ b/frontend/src/components/refine-question.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+/**
+ * RefineQuestionButton + RefineDrawer (T-7.3, REQ-68).
+ *
+ * Opt-in question refinement: an inline drawer (the dials-drawer
+ * progressive-disclosure pattern) hosting a back-and-forth exchange —
+ * one clarifying question at a time, then the composed refined
+ * question + suggested context. "Use this" applies the composition to
+ * the form; "Keep original" discards it; the draft is never modified
+ * until the user accepts. Scripted in the prototype — honest caption
+ * says the model-driven version lands in v0.2.
+ */
+
+import { useEffect, useRef, useState } from "react";
+import {
+ api,
+ type RefinedQuestion,
+ type RefinementQuestion,
+} from "@/lib/api/client";
+
+type Exchange =
+ | { stage: "questions"; items: RefinementQuestion[]; index: number; answers: Record }
+ | { stage: "composing" }
+ | { stage: "composed"; refined: RefinedQuestion };
+
+export function RefineQuestionButton({
+ draft,
+ onApply,
+}: {
+ draft: string;
+ onApply: (refined: RefinedQuestion) => void;
+}) {
+ const hasDraft = draft.trim().length > 0;
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+ {open && (
+ setOpen(false)}
+ onApply={(refined) => {
+ onApply(refined);
+ setOpen(false);
+ }}
+ />
+ )}
+ >
+ );
+}
+
+function RefineDrawer({
+ draft,
+ onClose,
+ onApply,
+}: {
+ draft: string;
+ onClose: () => void;
+ onApply: (refined: RefinedQuestion) => void;
+}) {
+ const [exchange, setExchange] = useState(null);
+ const [answer, setAnswer] = useState("");
+ const [error, setError] = useState(false);
+ const answerRef = useRef(null);
+
+ // Load the scripted question set on first open.
+ useEffect(() => {
+ let cancelled = false;
+ api
+ .getRefinementQuestions(draft)
+ .then((items) => {
+ if (!cancelled) {
+ setExchange({ stage: "questions", items, index: 0, answers: {} });
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setError(true);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [draft]);
+
+ // Escape closes the drawer — the draft stays untouched.
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ // Focus the answer box on every question change.
+ useEffect(() => {
+ if (exchange?.stage === "questions") answerRef.current?.focus();
+ }, [exchange]);
+
+ const current =
+ exchange?.stage === "questions" ? exchange.items[exchange.index] : undefined;
+
+ function submitAnswer() {
+ if (exchange?.stage !== "questions" || current === undefined) return;
+ if (!current.optional && answer.trim() === "") return;
+
+ const answers = { ...exchange.answers, [current.id]: answer.trim() };
+ setAnswer("");
+
+ if (exchange.index + 1 < exchange.items.length) {
+ setExchange({ ...exchange, index: exchange.index + 1, answers });
+ return;
+ }
+ // Last question answered — compose.
+ setExchange({ stage: "composing" });
+ api
+ .composeRefinedQuestion(draft, answers)
+ .then((refined) => {
+ setExchange({ stage: "composed", refined });
+ })
+ .catch(() => {
+ setError(true);
+ setExchange(null);
+ });
+ }
+
+ function restart() {
+ setAnswer("");
+ setError(false);
+ api
+ .getRefinementQuestions(draft)
+ .then((items) => {
+ setExchange({ stage: "questions", items, index: 0, answers: {} });
+ })
+ .catch(() => setError(true));
+ }
+
+ return (
+ {
+ if (event.key === "Enter" && exchange?.stage === "questions") {
+ event.preventDefault();
+ submitAnswer();
+ }
+ }}
+ >
+
+
+ Refine your question
+
+ {exchange?.stage === "questions" && (
+
+ {exchange.index + 1} of {exchange.items.length}
+
+ )}
+
+
+ {/* The draft stays visible — the exchange is about THIS text. */}
+
+ {draft}
+
+
+ {error && (
+
+ Refine is unavailable right now — try again.
+
+ )}
+
+ {exchange === null && !error && (
+ Preparing questions…
+ )}
+
+ {exchange?.stage === "questions" && current !== undefined && (
+
+
+
setAnswer(event.target.value)}
+ placeholder={current.placeholder}
+ className="mt-2 w-full rounded-card border border-hairline bg-paper-raised px-3 py-2 text-small text-ink placeholder:text-ink-faint"
+ />
+
+
+ {current.optional && (
+
+ )}
+
+
+
+ )}
+
+ {exchange?.stage === "composing" && (
+
+ Composing your refined question…
+
+ )}
+
+ {exchange?.stage === "composed" && (
+
+
Refined question
+
+ {exchange.refined.question}
+
+
Suggested context
+
+ {exchange.refined.context}
+
+
+
+
+
+
+
+ )}
+
+
+ Scripted refinement in the prototype — model-driven refine lands in v0.2.
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/three-click-path.test.tsx b/frontend/src/components/three-click-path.test.tsx
index a6de76e..27da6b5 100644
--- a/frontend/src/components/three-click-path.test.tsx
+++ b/frontend/src/components/three-click-path.test.tsx
@@ -13,7 +13,7 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { AskForm } from "@/components/ask-form";
+import { AskView } from "@/components/ask-view";
import { MethodologyCard } from "@/components/methodology-card";
import { RunView } from "@/components/run-view";
import { api } from "@/lib/api/client";
@@ -43,7 +43,9 @@ describe("3-click happy path (REQ-61)", () => {
let clicks = 0;
/* Screen 1 — Ask ***********************************************/
- const { unmount: unmountAsk } = render();
+ // Suggestions + refine are opt-in affordances — the default path
+ // (type → submit) never touches them, so the click count holds.
+ const { unmount: unmountAsk } = render();
const questionBox = screen.getByLabelText(/what do you want to know\?/i);
await user.type(questionBox, QUESTION);
// Click 1: Research this — defaults mean empty context is fine.
diff --git a/frontend/src/lib/api/mock/mock-client.ts b/frontend/src/lib/api/mock/mock-client.ts
index 434057c..1681cc0 100644
--- a/frontend/src/lib/api/mock/mock-client.ts
+++ b/frontend/src/lib/api/mock/mock-client.ts
@@ -318,7 +318,11 @@ export class MockApiClient implements ApiClient {
}
/** The scripted refine-dialogue questions (REQ-68). */
- async getRefinementQuestions(_draft: string): Promise {
+ async getRefinementQuestions(
+ // Draft keys the question set in v0.2; the prototype set is static.
+ _draft: string,
+ ): Promise {
+ void _draft;
return structuredClone(refinementQuestionsFixture);
}
--
2.43.0
From febe401c7b77f04af1d7c7542b6a92400ec0f4c6 Mon Sep 17 00:00:00 2001
From: coreci
Date: Sun, 27 Sep 2026 05:03:24 +0000
Subject: [PATCH 4/4] =?UTF-8?q?fix(P07):=20verify=20=E2=80=94=20Enter-guar?=
=?UTF-8?q?d=20on=20the=20refine=20input=20+=20focus=20return=20to=20trigg?=
=?UTF-8?q?er=20(P1s)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---ci---
phase: 7
milestone: v0.1
status: verify
persona: ci-lead-developer
requirements:
covered: [REQ-67, REQ-68]
partial: []
---/ci---
Four-layer verification of P7. Structural: AskSuggestions below the
form (click fills the box, no router.push in the path), refine
exchange state machine with all controls, boundary methods + mock
impls documented with v0.2 mappings, ask-form fully retired.
Behavioral: 86/86 (coverage — open-only suggestions, no-auto-submit,
disabled-without-draft, full exchange, skippable optionals, keep-
original, escape, 3-click on AskView). Security: no secrets/env/
innerHTML, user input renders as text nodes, import discipline
clean. Quality: both features opt-in; honest captions; tokens only.
P1s fixed in-phase (verify agent, +2 regression tests, suite 86→88):
- Enter-in-drawer: the section-level keydown guard correctly blocks
outer-form submission (probe-proven), but a jsdom blind spot left
it unpinned — guard added directly on the input (defense in depth)
and pinned by test
- Focus stranded on after drawer close — triggerRef focus
return per the house pattern (avatar→UserMenu), pinned by test
P2 logged: getRefinementQuestions ignores the draft (documented,
v0.2 territory). Gates re-run green: typecheck, lint 0/0, 88/88,
build.
---
frontend/src/components/ask-view.test.tsx | 44 ++++++++++++++++++++-
frontend/src/components/refine-question.tsx | 28 +++++++++++--
2 files changed, 67 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/ask-view.test.tsx b/frontend/src/components/ask-view.test.tsx
index 5fd5d4a..6a9fb3e 100644
--- a/frontend/src/components/ask-view.test.tsx
+++ b/frontend/src/components/ask-view.test.tsx
@@ -6,7 +6,8 @@
* fills the question box (editable, NO auto-submit)
* - Refine: disabled without a draft; the exchange completes and
* "Use this" fills question + context; "Keep original"/Escape leaves
- * the draft untouched
+ * the draft untouched; Enter advances the exchange without
+ * submitting the outer form; focus returns to the trigger on close
*/
import { cleanup, render, screen, waitFor } from "@testing-library/react";
@@ -219,4 +220,45 @@ describe("AskView", () => {
).toBeNull();
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(DRAFT);
});
+
+ it("refine: Enter advances the exchange, never submits the outer form", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(
+ screen.getByLabelText(/what do you want to know\?/i),
+ "What are the latest advances in solid-state batteries",
+ );
+ await user.click(screen.getByRole("button", { name: /Refine question/i }));
+ const answer = await screen.findByLabelText(/What specific part of this topic matters most to you\?/i);
+ await user.type(answer, "automotive applications");
+
+ // Enter inside the drawer (which lives in the outer