P7: Ask screen intelligence — suggestions + refine dialogue (REQ-67..68) #7
@@ -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"
|
||||
}
|
||||
+28
-34
@@ -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.
|
||||
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).
|
||||
@@ -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 |
|
||||
|
||||
+20
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AskForm />
|
||||
<AskView />
|
||||
|
||||
<p className="mt-block text-caption text-ink-faint">
|
||||
Prototype: runs on scripted mock data — no API keys, no live search.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(<AskForm />);
|
||||
|
||||
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(<AskForm />);
|
||||
|
||||
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(<AskForm />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setAskState({
|
||||
question: question.trim(),
|
||||
...(context.trim() !== "" ? { context: context.trim() } : {}),
|
||||
});
|
||||
router.push("/choose");
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="measure-narrow">
|
||||
<label htmlFor="question" className="block text-body font-medium">
|
||||
What do you want to know?
|
||||
</label>
|
||||
<textarea
|
||||
id="question"
|
||||
name="question"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
rows={3}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="e.g. What are the latest advances in solid-state batteries?"
|
||||
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="context"
|
||||
className="mt-6 block text-small font-medium text-ink-soft"
|
||||
>
|
||||
Anything else it should know?{" "}
|
||||
<span className="text-ink-faint">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="context"
|
||||
name="context"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Constraints, prior findings, the decision this feeds…"
|
||||
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-small text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Opening methodologies…" : "Research this"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<SuggestionsState>({ 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 (
|
||||
<section
|
||||
aria-label="Open questions from your research"
|
||||
className="mt-block"
|
||||
>
|
||||
<h2 className="text-small font-semibold uppercase tracking-wide text-ink-faint">
|
||||
Pick up an open question
|
||||
</h2>
|
||||
|
||||
{state.kind === "loading" && (
|
||||
<p className="mt-2 text-caption text-ink-faint">Checking your research…</p>
|
||||
)}
|
||||
{state.kind === "error" && (
|
||||
<p className="mt-2 text-caption text-ink-faint">
|
||||
Suggestions unavailable right now.
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "ready" && state.suggestions.length === 0 && (
|
||||
<p className="measure-reading mt-2 text-caption text-ink-faint">
|
||||
No open questions yet — they collect here as you run research.
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "ready" && state.suggestions.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 text-caption text-ink-soft">
|
||||
Unanswered questions from your past runs — one click loads them
|
||||
here for editing.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{state.suggestions.map((suggestion) => (
|
||||
<li key={suggestion.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(suggestion.question)}
|
||||
className="w-full rounded-card border border-hairline bg-paper-raised px-4 py-3 text-left transition-colors hover:border-ink-faint"
|
||||
>
|
||||
<p className="measure-reading text-small text-ink">
|
||||
{suggestion.question}
|
||||
</p>
|
||||
<p className="mt-1 text-caption text-ink-faint">
|
||||
From your research — {suggestion.sourceReportTitle}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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; 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";
|
||||
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(<AskView />);
|
||||
|
||||
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(<AskView />);
|
||||
// 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(<AskView />);
|
||||
|
||||
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(<AskView />);
|
||||
|
||||
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(<AskView />);
|
||||
|
||||
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(<AskView />);
|
||||
|
||||
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(<AskView />);
|
||||
|
||||
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(<AskView />);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("refine: Enter advances the exchange, never submits the outer form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
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 <form>) must
|
||||
// advance the exchange — not submit and navigate to /choose.
|
||||
await user.keyboard("{Enter}");
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByLabelText(/What decision or action will this research inform\?/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refine: focus returns to the trigger after the drawer closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AskView />);
|
||||
|
||||
await user.type(
|
||||
screen.getByLabelText(/what do you want to know\?/i),
|
||||
"What are the latest advances in solid-state batteries",
|
||||
);
|
||||
const refine = screen.getByRole("button", { name: /Refine question/i });
|
||||
await user.click(refine);
|
||||
await screen.findByRole("region", { name: /Refine your question/i });
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(
|
||||
screen.queryByRole("region", { name: /Refine your question/i }),
|
||||
).toBeNull();
|
||||
// The keyboard user is not stranded on <body>.
|
||||
expect(refine).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setAskState({
|
||||
question: question.trim(),
|
||||
...(context.trim() !== "" ? { context: context.trim() } : {}),
|
||||
});
|
||||
router.push("/choose");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="measure-narrow">
|
||||
<label htmlFor="question" className="block text-body font-medium">
|
||||
What do you want to know?
|
||||
</label>
|
||||
<textarea
|
||||
id="question"
|
||||
name="question"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
rows={3}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="e.g. What are the latest advances in solid-state batteries?"
|
||||
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
{/* Refine — opt-in, inline, needs a draft to act on (REQ-68). */}
|
||||
<RefineQuestionButton
|
||||
draft={question}
|
||||
onApply={(refined) => {
|
||||
setQuestion(refined.question);
|
||||
if (refined.context.trim() !== "") setContext(refined.context);
|
||||
}}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="context"
|
||||
className="mt-6 block text-small font-medium text-ink-soft"
|
||||
>
|
||||
Anything else it should know?{" "}
|
||||
<span className="text-ink-faint">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="context"
|
||||
name="context"
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Constraints, prior findings, the decision this feeds…"
|
||||
className="mt-2 w-full resize-none rounded-card border border-hairline bg-paper-raised px-4 py-3 text-small text-ink placeholder:text-ink-faint"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="mt-8 rounded-pill bg-accent px-7 py-3 text-body font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Opening methodologies…" : "Research this"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Suggestions — below the primary action, never competing (REQ-67). */}
|
||||
<AskSuggestions onPick={(suggested) => setQuestion(suggested)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"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. Enter advances the exchange (never the outer
|
||||
* form), and focus returns to the trigger when the drawer closes.
|
||||
* 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<string, string> }
|
||||
| { 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);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
// Focus returns to the trigger — the drawer never strands the
|
||||
// keyboard user on <body> (house pattern: avatar → UserMenu).
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={!hasDraft}
|
||||
onClick={() => setOpen(true)}
|
||||
className="mt-3 rounded-pill border border-hairline bg-paper-raised px-4 py-1.5 text-caption font-medium text-ink-soft transition-colors hover:border-ink-faint hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
✎ Refine question
|
||||
</button>
|
||||
{open && (
|
||||
<RefineDrawer
|
||||
draft={draft}
|
||||
onClose={close}
|
||||
onApply={(refined) => {
|
||||
onApply(refined);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RefineDrawer({
|
||||
draft,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
draft: string;
|
||||
onClose: () => void;
|
||||
onApply: (refined: RefinedQuestion) => void;
|
||||
}) {
|
||||
const [exchange, setExchange] = useState<Exchange | null>(null);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const answerRef = useRef<HTMLInputElement>(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 (
|
||||
<section
|
||||
aria-label="Refine your question"
|
||||
className="mt-3 rounded-card border border-hairline bg-accent-wash px-5 py-5"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && exchange?.stage === "questions") {
|
||||
event.preventDefault();
|
||||
submitAnswer();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className="text-small font-semibold uppercase tracking-wide text-accent-ink">
|
||||
Refine your question
|
||||
</h3>
|
||||
{exchange?.stage === "questions" && (
|
||||
<p className="text-caption text-ink-faint" role="status">
|
||||
{exchange.index + 1} of {exchange.items.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The draft stays visible — the exchange is about THIS text. */}
|
||||
<blockquote className="measure-reading mt-2 border-l-2 border-accent pl-3 text-small text-ink">
|
||||
{draft}
|
||||
</blockquote>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-caption text-danger">
|
||||
Refine is unavailable right now — try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{exchange === null && !error && (
|
||||
<p className="mt-3 text-caption text-ink-faint">Preparing questions…</p>
|
||||
)}
|
||||
|
||||
{exchange?.stage === "questions" && current !== undefined && (
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="refine-answer"
|
||||
className="block text-small font-medium text-ink"
|
||||
>
|
||||
{current.prompt}
|
||||
{!current.optional && (
|
||||
<span className="text-ink-faint"> (required)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
ref={answerRef}
|
||||
id="refine-answer"
|
||||
value={answer}
|
||||
onChange={(event) => setAnswer(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
// Enter advances the exchange — the input lives inside the
|
||||
// outer <form>, so without this guard Enter would submit
|
||||
// the form and navigate away mid-refine.
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitAnswer();
|
||||
}
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitAnswer}
|
||||
disabled={!current.optional && answer.trim() === ""}
|
||||
className="rounded-pill bg-accent px-4 py-1.5 text-caption font-medium text-paper-raised transition-colors hover:bg-accent-strong disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{exchange.index + 1 < exchange.items.length ? "Next" : "Compose question"}
|
||||
</button>
|
||||
{current.optional && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitAnswer}
|
||||
className="rounded-pill border border-hairline px-4 py-1.5 text-caption text-ink-soft transition-colors hover:text-ink"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-pill px-3 py-1.5 text-caption text-ink-faint transition-colors hover:text-ink"
|
||||
>
|
||||
Keep original
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exchange?.stage === "composing" && (
|
||||
<p className="mt-4 text-caption text-ink-faint" role="status">
|
||||
Composing your refined question…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{exchange?.stage === "composed" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-caption font-medium text-ink">Refined question</p>
|
||||
<p className="measure-reading mt-1 rounded-card border border-hairline bg-paper-raised px-4 py-3 text-body text-ink">
|
||||
{exchange.refined.question}
|
||||
</p>
|
||||
<p className="mt-3 text-caption font-medium text-ink">Suggested context</p>
|
||||
<p className="measure-reading mt-1 rounded-card border border-hairline bg-paper-raised px-4 py-3 text-small text-ink-soft">
|
||||
{exchange.refined.context}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply(exchange.refined)}
|
||||
className="rounded-pill bg-accent px-4 py-1.5 text-caption font-medium text-paper-raised transition-colors hover:bg-accent-strong"
|
||||
>
|
||||
Use this
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={restart}
|
||||
className="rounded-pill border border-hairline px-4 py-1.5 text-caption text-ink-soft transition-colors hover:text-ink"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ml-auto rounded-pill px-3 py-1.5 text-caption text-ink-faint transition-colors hover:text-ink"
|
||||
>
|
||||
Keep original
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 border-t border-hairline pt-2 text-caption text-ink-faint">
|
||||
Scripted refinement in the prototype — model-driven refine lands in v0.2.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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(<AskForm />);
|
||||
// Suggestions + refine are opt-in affordances — the default path
|
||||
// (type → submit) never touches them, so the click count holds.
|
||||
const { unmount: unmountAsk } = render(<AskView />);
|
||||
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.
|
||||
|
||||
@@ -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<Settings>): Promise<Settings>;
|
||||
|
||||
/**
|
||||
* 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<SuggestedQuery[]>;
|
||||
|
||||
/**
|
||||
* 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<RefinementQuestion[]>;
|
||||
|
||||
/**
|
||||
* 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<string, string>,
|
||||
): Promise<RefinedQuestion>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>,
|
||||
): 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(" ") };
|
||||
}
|
||||
@@ -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,32 @@ 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<SuggestedQuery[]> {
|
||||
return structuredClone(buildSuggestedQueries());
|
||||
}
|
||||
|
||||
/** The scripted refine-dialogue questions (REQ-68). */
|
||||
async getRefinementQuestions(
|
||||
// Draft keys the question set in v0.2; the prototype set is static.
|
||||
_draft: string,
|
||||
): Promise<RefinementQuestion[]> {
|
||||
void _draft;
|
||||
return structuredClone(refinementQuestionsFixture);
|
||||
}
|
||||
|
||||
/** Deterministic refine composer (REQ-68; model-driven in v0.2). */
|
||||
async composeRefinedQuestion(
|
||||
draft: string,
|
||||
answers: Record<string, string>,
|
||||
): Promise<RefinedQuestion> {
|
||||
return composeRefinedQuestion(draft, answers);
|
||||
}
|
||||
|
||||
/** The scripted run for the prototype's demo question. */
|
||||
#defaultRun(): ResearchRun {
|
||||
return buildScriptedRun({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user