P7: Ask screen intelligence — suggestions + refine dialogue (REQ-67..68) #7

Merged
coreci-bot merged 4 commits from phase/07-ask-suggestions-refine into milestone/v0.1-prototype 2026-09-27 05:03:38 +00:00
18 changed files with 1106 additions and 210 deletions
+4 -4
View File
@@ -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
View File
@@ -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).
+6 -1
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
+7 -5
View File
@@ -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>
);
}
}
-82
View File
@@ -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();
});
});
-78
View File
@@ -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>
);
}
+264
View File
@@ -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();
});
});
+96
View File
@@ -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>
);
}
+297
View File
@@ -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.
+29
View File
@@ -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");
});
});
+100
View File
@@ -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(" ") };
}
+34
View File
@@ -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({
+37
View File
@@ -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;
}