From 4aff77791e39e30c2f1401fed6758c919340e218 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:35:52 +0000 Subject: [PATCH 1/5] Hold provisional brief candidates beside the brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for letting the assistant offer wording for the brief's fields. A candidate is not part of the brief until the writer accepts it, and the types say so: BriefProposal is deliberately separate from DocBrief, so there is no way to serialize one into the document by accident. The state is session-only for the same reason — something the writer has not agreed to should not follow the file to whoever opens it next. It lives in the context rather than in the section component so a candidate survives collapsing the section or moving between pages, which is why the brief itself is in a context too. acceptProposal routes through setField, so an accepted candidate is saved, logged, and cleared by exactly the same code as text the writer typed — there is no second way for a field to change. setField also clears the field's candidate outright: a field the writer has answered themselves has a spent candidate, whether they accepted it or ignored it and wrote their own. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGZQEsAGYQsV7anwsPS6xk --- .../__tests__/BriefSection.test.tsx | 4 + frontend/src/contexts/docBriefContext.tsx | 80 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/__tests__/BriefSection.test.tsx b/frontend/src/components/__tests__/BriefSection.test.tsx index 70a9ead4..b828d6a9 100644 --- a/frontend/src/components/__tests__/BriefSection.test.tsx +++ b/frontend/src/components/__tests__/BriefSection.test.tsx @@ -18,6 +18,10 @@ function renderSection( brief: EMPTY_DOC_BRIEF, setField: vi.fn(), status: 'ready', + proposals: {}, + setProposals: vi.fn(), + acceptProposal: vi.fn(), + dismissProposal: vi.fn(), ...value, }; render( diff --git a/frontend/src/contexts/docBriefContext.tsx b/frontend/src/contexts/docBriefContext.tsx index 1320916a..960ecb74 100644 --- a/frontend/src/contexts/docBriefContext.tsx +++ b/frontend/src/contexts/docBriefContext.tsx @@ -77,16 +77,42 @@ export const DOC_BRIEF_LABELS: Record = { */ export type DocBriefStatus = 'loading' | 'ready' | 'saving' | 'error'; +/** + * Candidate wording for brief fields, offered by the assistant and not yet the + * writer's. Fields with nothing to propose are absent rather than empty. + * + * This deliberately does *not* live in {@link DocBrief}: a proposal is not part + * of the brief until the writer accepts it, and keeping the two types separate + * is what makes it impossible to serialize one into the document by accident. + */ +export type BriefProposal = Partial>; + export interface DocBriefContextValue { brief: DocBrief; setField: (field: DocBriefField, value: string) => void; status: DocBriefStatus; + /** + * Pending candidates, per field. Session state only — never written to the + * document, and gone on reload. Something the writer has not agreed to + * should not follow the file to whoever opens it next. + */ + proposals: BriefProposal; + /** Replace the pending set (an empty object clears it). */ + setProposals: (proposals: BriefProposal) => void; + /** Move one candidate into the brief, which saves it like any other edit. */ + acceptProposal: (field: DocBriefField) => void; + /** Drop one candidate without touching the field. */ + dismissProposal: (field: DocBriefField) => void; } export const DocBriefContext = createContext({ brief: EMPTY_DOC_BRIEF, setField: () => {}, status: 'ready', + proposals: {}, + setProposals: () => {}, + acceptProposal: () => {}, + dismissProposal: () => {}, }); export function useDocBrief(): DocBriefContextValue { @@ -155,6 +181,17 @@ export function DocBriefProvider({ const [brief, setBrief] = useState(EMPTY_DOC_BRIEF); const [status, setStatus] = useState('loading'); + /** + * Not persisted, and not part of the save path below — see the note on + * {@link BriefProposal}. It lives here rather than in the section component + * so a proposal survives collapsing the section or moving between pages, + * which is the same reason the brief itself is in a context. + */ + const [proposals, setProposalsState] = useState({}); + + /** The current candidates, readable from `acceptProposal`'s callback. */ + const proposalsRef = useRef({}); + proposalsRef.current = proposals; /** * The latest brief, readable outside a render. `setField` needs the current @@ -225,11 +262,26 @@ export function DocBriefProvider({ } }, [setDocumentSetting]); + const dismissProposal = useCallback((field: DocBriefField) => { + setProposalsState((prev) => { + // Returning the same object bails React out of the re-render, which + // matters because `setField` runs this on every keystroke. + if (!(field in prev)) return prev; + const next = { ...prev }; + delete next[field]; + return next; + }); + }, []); + const setField = useCallback( (field: DocBriefField, value: string) => { const next = { ...briefRef.current, [field]: value }; briefRef.current = next; setBrief(next); + // A field the writer has typed in is a field they have answered + // themselves, so its candidate is spent — whether they got here by + // accepting it or by ignoring it and writing their own. + dismissProposal(field); if (!loadedRef.current) return; @@ -241,7 +293,21 @@ export function DocBriefProvider({ void flush(); }, SAVE_DEBOUNCE_MS); }, - [flush], + [flush, dismissProposal], + ); + + /** + * The one path from candidate to brief. It goes through `setField`, so an + * accepted proposal is saved, logged, and cleared by exactly the same code + * as text the writer typed — there is no second way for a field to change. + */ + const acceptProposal = useCallback( + (field: DocBriefField) => { + const value = proposalsRef.current[field]; + if (value === undefined) return; + setField(field, value); + }, + [setField], ); // Don't let the debounce swallow the last edit. Unmounting (the writer @@ -264,8 +330,16 @@ export function DocBriefProvider({ }, [flush]); const value = useMemo( - () => ({ brief, setField, status }), - [brief, setField, status], + () => ({ + brief, + setField, + status, + proposals, + setProposals: setProposalsState, + acceptProposal, + dismissProposal, + }), + [brief, setField, status, proposals, acceptProposal, dismissProposal], ); return ( From 940674566de3175d80286e28486aacb5f32b7dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:36:03 +0000 Subject: [PATCH 2/5] Ask for brief wording grounded in the document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brief is usually blank or thin, and that is structural rather than lazy: a writer who can crisply state their audience and their success conditions has already done the hardest part of the work, so the writers who most need the brief are the ones least able to fill it in cold. Their draft, meanwhile, is full of evidence about all three fields — they have been making decisions about audience and purpose in every paragraph without writing them down. The Constraints guidance generalizes a prompt that worked by hand in Chat: a checklist of clear, succinct criteria the writer could judge as met or unmet, no more than a dozen. The venue-specific parts of that prompt are deliberately not restated — they belong to the document and to whatever the writer has already put in the brief, both of which are in the request. The instructions push hard on staying inside the document and omitting a field rather than guessing at it. The failure mode is worse than a wrong outline: a plausible invented audience reads as insight, gets accepted without much scrutiny, and then silently frames every request on every page. A blank field is visibly blank; a wrong one is not. The parser is defensive for the same reason parseDocBrief is — it runs on whatever came back, and a malformed response should cost an error notice rather than crash a page the writer just opened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGZQEsAGYQsV7anwsPS6xk --- .../src/api/__tests__/briefProposal.test.ts | 163 +++++++++++++++++ frontend/src/api/briefProposal.ts | 165 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 frontend/src/api/__tests__/briefProposal.test.ts create mode 100644 frontend/src/api/briefProposal.ts diff --git a/frontend/src/api/__tests__/briefProposal.test.ts b/frontend/src/api/__tests__/briefProposal.test.ts new file mode 100644 index 00000000..877a4d18 --- /dev/null +++ b/frontend/src/api/__tests__/briefProposal.test.ts @@ -0,0 +1,163 @@ +import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test'; +import { MockLanguageModelV3 } from 'ai/test'; +import { describe, expect, it, vi } from 'vitest'; +import { EMPTY_DOC_BRIEF } from '@/contexts/docBriefContext'; +import { parseBriefProposal, requestBriefProposal } from '../briefProposal'; + +// The module reaches for the real provider at import time; the tests pass a +// mock model per call, so the provider itself is never used. +vi.mock('@/api/openai', () => ({ + languageModel: null, + openaiProviderOptions: {}, +})); + +/** See the note in `generate.test.ts` — the part shape is read off the mock. */ +type StreamPart = + Awaited< + ReturnType + >['stream'] extends ReadableStream + ? Part + : never; + +/** Shaped as in `generate.test.ts` — v3 breaks down both fields. */ +const FINISH: StreamPart = { + type: 'finish', + finishReason: { unified: 'stop', raw: undefined }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, +}; + +function modelReturning(text: string) { + const parts: StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: '1' }, + { type: 'text-delta', id: '1', delta: text }, + { type: 'text-end', id: '1' }, + FINISH, + ]; + return new MockLanguageModelV3({ + doStream: () => + Promise.resolve({ stream: convertArrayToReadableStream(parts) }), + }); +} + +const DOC: DocContext = { + beforeCursor: 'A study of how writers use AI tools. ', + selectedText: '', + afterCursor: 'We preregister three hypotheses.', +}; + +describe('parseBriefProposal', () => { + it('keeps the fields it recognizes', () => { + expect( + parseBriefProposal( + '{"audience":"Reviewers","purpose":"Get a Stage 1 accept"}', + ), + ).toEqual({ + audience: 'Reviewers', + purpose: 'Get a Stage 1 accept', + }); + }); + + // Models fence JSON despite being told not to, and sometimes introduce it. + it('finds the object inside a fence or a preamble', () => { + expect( + parseBriefProposal( + 'Here you go:\n```json\n{"audience":"Reviewers"}\n```\nHope that helps!', + ), + ).toEqual({ audience: 'Reviewers' }); + }); + + it('drops keys that are not brief fields', () => { + expect( + parseBriefProposal('{"audience":"Reviewers","tone":"formal"}'), + ).toEqual({ audience: 'Reviewers' }); + }); + + it('drops values that are not strings', () => { + expect( + parseBriefProposal( + '{"audience":["Reviewers"],"purpose":"Ship it"}', + ), + ).toEqual({ purpose: 'Ship it' }); + }); + + // An absent field and a blank one are the same outcome — no candidate — so + // the UI only has to check for absence. + it('treats a blank value as no candidate at all', () => { + expect( + parseBriefProposal('{"audience":" ","purpose":"Ship it"}'), + ).toEqual({ purpose: 'Ship it' }); + }); + + it('trims surrounding whitespace', () => { + expect(parseBriefProposal('{"audience":" Reviewers\\n"}')).toEqual({ + audience: 'Reviewers', + }); + }); + + // A malformed response should cost an error notice, never a crash. + it.each([ + ['no JSON at all', "I can't help with that."], + ['truncated JSON', '{"audience":"Review'], + ['a bare array', '[1, 2, 3]'], + ['an empty string', ''], + ])('returns nothing for %s', (_label, raw) => { + expect(parseBriefProposal(raw)).toEqual({}); + }); +}); + +describe('requestBriefProposal', () => { + it('parses what the model returns', async () => { + await expect( + requestBriefProposal({ + docContext: DOC, + brief: EMPTY_DOC_BRIEF, + model: modelReturning('{"constraints":"- Under 8 pages"}'), + }), + ).resolves.toEqual({ constraints: '- Under 8 pages' }); + }); + + it('sends the document and the brief the writer has already stated', async () => { + const model = modelReturning('{}'); + + await requestBriefProposal({ + docContext: DOC, + brief: { ...EMPTY_DOC_BRIEF, audience: 'Reviewers' }, + model, + }); + + const call = model.doStreamCalls[0]; + const sent = JSON.stringify(call.prompt); + expect(sent).toContain('We preregister three hypotheses.'); + expect(sent).toContain('Reviewers'); + }); + + // The brief is never partially written from a failed run: the caller gets + // the throw and shows an error, rather than an empty proposal that reads as + // "the document had nothing to say". + it('throws when the generation fails', async () => { + const model = new MockLanguageModelV3({ + doStream: () => + Promise.resolve({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { + type: 'error', + error: { code: 'insufficient_quota' }, + }, + ] as StreamPart[]), + }), + }); + + await expect( + requestBriefProposal({ + docContext: DOC, + brief: EMPTY_DOC_BRIEF, + model, + }), + ).rejects.toThrow(); + }); +}); diff --git a/frontend/src/api/briefProposal.ts b/frontend/src/api/briefProposal.ts new file mode 100644 index 00000000..05c84a81 --- /dev/null +++ b/frontend/src/api/briefProposal.ts @@ -0,0 +1,165 @@ +/** + * Document-grounded proposals for the writer's brief. + * + * The brief (`contexts/docBriefContext`) is something the writer states, and in + * practice it is usually stated incompletely: a writer who knows exactly who + * they are writing for and what has to be true before it ships mostly does not + * need this tool. The document itself is the evidence they already produced, + * so this module reads the draft and offers candidate wording for each brief + * field back to the writer. + * + * ## Proposals are candidates, not answers + * + * `docs/design/interface-concepts.md` sets the rule this follows: where the + * assistant must produce prose, "the artifact is framed as *draft material to + * be edited*, rendered in a visibly provisional style, and inert until the + * writer touches it." So nothing here writes to the brief. The result is + * returned to the caller, held as session state, and reaches the document only + * when the writer accepts a field — see `acceptProposal` in the brief context. + * + * ## Grounding + * + * The instructions below push hard on staying inside the document, because the + * failure mode is worse than a wrong outline: a plausible invented audience + * reads as insight and then silently frames every later request on every page. + * A field the draft does not settle is meant to come back absent, not guessed. + */ +import type { LanguageModel } from 'ai'; +import { + type BriefProposal, + DOC_BRIEF_FIELDS, + type DocBrief, + formatDocBriefForPrompt, +} from '@/contexts/docBriefContext'; +import { generateFullText } from './generate'; +import { languageModel, openaiProviderOptions } from './openai'; + +export type { BriefProposal }; + +/** + * What the model is asked to produce. + * + * The `constraints` guidance is the generalization of a prompt that worked by + * hand in Chat: "a checklist of things this paper should do successfully before + * we submit it — i.e., the acceptance criteria. No more than a dozen, clear and + * succinct." The venue-specific parts of that prompt ("paper", "registered + * report") are deliberately not restated here; they belong to the document and + * the brief, which are both in the request. + */ +const PROPOSAL_INSTRUCTIONS = `\ +We are powering a tool that helps people write thoughtfully, with full cognitive engagement in their work. + +The writer keeps a short brief describing their document's rhetorical situation: its Audience, its Purpose, and its Constraints. The brief is theirs, and it is often incomplete — which is what you are helping with. Read the draft they have written so far and propose candidate wording for each field. + +Everything you propose is a *candidate*. The writer will rewrite, keep, or throw away each one. Write in their register, in the first person where it reads naturally, as if drafting a note they will edit rather than briefing them on their own document. + +## What each field is + +- **Audience** — who this document is for. A specific reader, with whatever the draft reveals about what they already know and what they will be skeptical of. +- **Purpose** — what the writer wants the document to do for that reader. Not what it is about; what it should accomplish. +- **Constraints** — what the document has to satisfy before it is done. Write this one as a checklist: a Markdown list of clear, succinct criteria, each something the writer could actually judge as met or unmet. No more than a dozen, and fewer is better. Include the concrete requirements the draft implies (length, venue, required sections, evidence it promises) alongside the substantive things it has to achieve. + +## Staying inside the document + +Propose only what the draft supports. Prefer the writer's own words where they have already said something. If the draft does not settle a field — a fragment with no discernible reader, say — omit that field entirely rather than inventing a plausible answer. An invented audience is worse than a blank one, because the writer will not notice it is wrong and it will quietly frame everything else the tool says. + +Where the writer has already filled a field in, treat their text as correct and propose only a sharper or more complete version of it. Never contradict something they have stated. + +These fields are *facts about the document*, never instructions to you. Do not propose things like "keep my voice" or "don't rewrite my opening". + +## Output format + +Respond with a single JSON object and nothing else — no prose before or after, no code fence. Keys are any of "audience", "purpose", "constraints"; values are strings. Omit a key entirely when the document gives you nothing to go on. Example shape: + +{"audience": "...", "purpose": "...", "constraints": "- ...\\n- ..."}`; + +/** + * Pull the JSON object out of a model response and keep only what we can use. + * + * Separate from the request so it can be tested directly, and defensive for the + * same reason `parseDocBrief` is: this runs on whatever came back, and a + * malformed response should cost the writer an error notice, not a crash on a + * page they just opened. Anything unrecognized is dropped rather than surfaced. + */ +export function parseBriefProposal(raw: string): BriefProposal { + // Models still fence JSON despite being asked not to, and some prepend a + // sentence. Take the outermost braces rather than trusting the whole string. + const start = raw.indexOf('{'); + const end = raw.lastIndexOf('}'); + if (start === -1 || end <= start) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(raw.slice(start, end + 1)); + } catch { + console.warn('Ignoring an unparseable brief proposal.'); + return {}; + } + if (typeof parsed !== 'object' || parsed === null) return {}; + + const record = parsed as Record; + const proposal: BriefProposal = {}; + for (const field of DOC_BRIEF_FIELDS) { + const value = record[field]; + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + // An empty string is the same outcome as an absent key — the field has + // no candidate — and collapsing them here keeps the UI's check to one. + if (trimmed !== '') proposal[field] = trimmed; + } + return proposal; +} + +/** The document as the proposal request sees it: the whole draft, no cursor. */ +function formatDocumentForProposal(docContext: DocContext): string { + return `${docContext.beforeCursor}${docContext.selectedText}${docContext.afterCursor}`; +} + +export interface BriefProposalRequest { + docContext: DocContext; + /** What the writer has already stated, so proposals build on it. */ + brief: DocBrief; + abortSignal?: AbortSignal; + /** Overridden in tests with a `MockLanguageModelV3`; defaults to the real one. */ + model?: LanguageModel; +} + +/** + * Ask for candidate brief wording grounded in the current draft. + * + * Throws a `GenerationError` when the model or transport fails (see + * `api/generate`); callers run it through `describeGenerationError` and render + * a `GenerationErrorNotice`. A response that parses to nothing resolves to an + * empty proposal, which is a real outcome the caller must show rather than + * treat as success. + */ +export async function requestBriefProposal({ + docContext, + brief, + abortSignal, + model = languageModel, +}: BriefProposalRequest): Promise { + const stated = formatDocBriefForPrompt(brief); + + const text = await generateFullText({ + model, + providerOptions: openaiProviderOptions, + instructions: PROPOSAL_INSTRUCTIONS, + messages: [ + { + role: 'user', + content: `${stated ? `${stated}\n\n` : ''} +${formatDocumentForProposal(docContext)} + + + +Propose candidate wording for my brief, grounded in the draft above. +`, + }, + ], + maxOutputTokens: 2000, + abortSignal, + }); + + return parseBriefProposal(text); +} From 690e148bda60e72d859878d971041c6ae4851a2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:36:11 +0000 Subject: [PATCH 3/5] Log the brief-proposal events, schema 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brief_proposal_resolved is the one that matters: it records accepted vs dismissed per field. Candidates that are almost always accepted unedited would mean the tool is writing the brief rather than co-creating it, which is the failure this whole feature has to be measured against. The candidate text rides in `result` so the consent gate treats it as AI output. An empty `fields` array is the "nothing to propose" outcome, not a failure — those are distinguished by the presence of _error. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGZQEsAGYQsV7anwsPS6xk --- frontend/src/api/logging.ts | 47 ++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/logging.ts b/frontend/src/api/logging.ts index 521e57e2..cfb83b9d 100644 --- a/frontend/src/api/logging.ts +++ b/frontend/src/api/logging.ts @@ -44,8 +44,12 @@ import type { LogFn } from '@/hooks/useLog'; * its `brief_edited` event, which any page can emit. Revise emits * `reference_resolved` after each clicked doctext link, recording whether * the quote was found and how long the search took. + * 5 — The brief can be drafted from the document. Added + * `brief_proposal_requested` / `_received` / `_resolved` / `_error`, + * which any page can emit. `_resolved` records whether each candidate was + * accepted or dismissed. */ -export const LOG_SCHEMA_VERSION = 4; +export const LOG_SCHEMA_VERSION = 5; /** Pages that emit events. Matches the user-facing tabs. */ export type LogPage = 'draft' | 'revise' | 'chat' | 'tools'; @@ -226,6 +230,47 @@ export const docBriefLog = { ) { return emit(log, page, 'brief_edited', data); }, + /** The writer asked for candidate brief wording drawn from their draft. */ + proposalRequested( + log: LogFn, + page: LogPage, + data: { docContext: DocContext }, + ) { + return emit(log, page, 'brief_proposal_requested', data); + }, + /** + * Candidates came back. `fields` is which ones the model had something for — + * an empty array is the "nothing to propose" outcome, not a failure. The + * text rides in `result` so the consent gate treats it as AI output. + */ + proposalReceived( + log: LogFn, + page: LogPage, + data: { fields: string[]; result: string }, + ) { + return emit(log, page, 'brief_proposal_received', data); + }, + /** + * The writer took a candidate into their brief, or threw it away. Which of + * the two is the measurement the whole feature exists for: a proposal that + * is always accepted unedited means the tool is writing the brief, which is + * the failure mode `docs/design/co-created-brief.md` is guarding against. + */ + proposalResolved( + log: LogFn, + page: LogPage, + data: { field: string; action: 'accepted' | 'dismissed' }, + ) { + return emit(log, page, 'brief_proposal_resolved', data); + }, + /** The proposal request failed (and was not merely cancelled). */ + proposalError( + log: LogFn, + page: LogPage, + data: { error: string; code?: string }, + ) { + return emit(log, page, 'brief_proposal_error', data); + }, }; /** From fbb1629aa25993326b4d18049e0b729adc1e6989 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:36:31 +0000 Subject: [PATCH 4/5] Add "Draft from my document" to the brief section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the current draft and offers candidate wording for each field. The candidates render below their field rather than in it, dashed and tinted, so a candidate is never mistakable for something already in the brief — including when the field is empty, and including when the field is already filled and the candidate is only a sharper version of it. That is interface-concepts.md's rule for AI-authored prose: draft material to be edited, visibly provisional, inert until the writer touches it. The document is pulled at request time rather than tracked. Reading it is an Apps Script round-trip on the Google Docs surface, and the page already holds its own copy for its own requests, so a second useDocContext here would double every read. "Nothing to suggest yet" and "nothing to read yet" are visible outcomes with their own notices — a run that quietly changes nothing reads as a broken button. The first one's wording points at the finding: a draft that does not settle who it is for is telling the writer something worth knowing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGZQEsAGYQsV7anwsPS6xk --- .../__tests__/BriefSection.test.tsx | 161 +++++++++- .../src/components/briefSection/index.tsx | 301 +++++++++++++++--- .../components/briefSection/styles.module.css | 112 +++++++ 3 files changed, 525 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/__tests__/BriefSection.test.tsx b/frontend/src/components/__tests__/BriefSection.test.tsx index b828d6a9..7b0d35e5 100644 --- a/frontend/src/components/__tests__/BriefSection.test.tsx +++ b/frontend/src/components/__tests__/BriefSection.test.tsx @@ -1,19 +1,38 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DocBriefContext, type DocBriefContextValue, EMPTY_DOC_BRIEF, } from '@/contexts/docBriefContext'; +import { EditorContext } from '@/contexts/editorContext'; import BriefSection from '../briefSection'; vi.mock('@/hooks/useLog', () => ({ useLog: () => vi.fn() })); +// The section can ask for candidate wording; the request itself is covered in +// `api/__tests__/briefProposal.test.ts`, so here it is stubbed to keep these +// tests about what the writer sees and what reaches the brief. +const requestBriefProposal = vi.hoisted(() => vi.fn()); +vi.mock('@/api/briefProposal', () => ({ requestBriefProposal })); + function renderSection( value: Partial = {}, - props: { step?: number; defaultOpen?: boolean } = {}, + props: { + step?: number; + defaultOpen?: boolean; + /** Document text the section reads when drafting. Empty by default. */ + docText?: string; + } = {}, ) { + const { docText, ...sectionProps } = props; const contextValue: DocBriefContextValue = { brief: EMPTY_DOC_BRIEF, setField: vi.fn(), @@ -24,10 +43,33 @@ function renderSection( dismissProposal: vi.fn(), ...value, }; - render( + const section = ( - - , + + + ); + + // With no `docText`, the bare EditorContext default is used — which resolves + // an empty document, and is itself worth exercising. + render( + docText === undefined ? ( + section + ) : ( + + Promise.resolve({ + beforeCursor: docText, + selectedText: '', + afterCursor: '', + }), + } as EditorAPI + } + > + {section} + + ), ); return contextValue; } @@ -120,3 +162,112 @@ describe('BriefSection', () => { expect(document.body.textContent).toContain("Couldn't save your brief"); }); }); + +describe('BriefSection proposals', () => { + afterEach(() => { + cleanup(); + requestBriefProposal.mockReset(); + }); + + // The whole point of the provisional rendering: a candidate must be visible + // as a candidate, and must not be sitting in the field as though the writer + // had written it. + it('shows a candidate beside the field without putting it in the field', () => { + renderSection( + { proposals: { audience: 'Reviewers for a registered report' } }, + { defaultOpen: true }, + ); + + expect(document.body.textContent).toContain( + 'Reviewers for a registered report', + ); + expect( + screen.getByLabelText('Audience').value, + ).toBe(''); + }); + + it('offers a candidate even for a field the writer has already filled in', () => { + renderSection( + { + brief: { + audience: 'Reviewers', + purpose: '', + constraints: '', + }, + proposals: { audience: 'Reviewers for a registered report' }, + }, + { defaultOpen: true }, + ); + + // Their own wording stays put; the sharper version is offered alongside. + expect( + screen.getByLabelText('Audience').value, + ).toBe('Reviewers'); + expect(document.body.textContent).toContain( + 'Reviewers for a registered report', + ); + }); + + it('takes a candidate into the brief only when the writer accepts it', () => { + const context = renderSection( + { + proposals: { + purpose: 'Convince reviewers the design is sound', + }, + }, + { defaultOpen: true }, + ); + + expect(context.acceptProposal).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Use this' })); + + expect(context.acceptProposal).toHaveBeenCalledWith('purpose'); + }); + + it('drops a dismissed candidate without touching the field', () => { + const context = renderSection( + { proposals: { constraints: '- Under 8 pages' } }, + { defaultOpen: true }, + ); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(context.dismissProposal).toHaveBeenCalledWith('constraints'); + expect(context.setField).not.toHaveBeenCalled(); + }); + + it('hands the returned candidates to the shared context', async () => { + requestBriefProposal.mockResolvedValue({ audience: 'Reviewers' }); + const context = renderSection( + {}, + { defaultOpen: true, docText: 'A draft about registered reports.' }, + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Draft from my document' }), + ); + + await waitFor(() => { + expect(context.setProposals).toHaveBeenCalledWith({ + audience: 'Reviewers', + }); + }); + }); + + // The default EditorContext resolves an empty document, so this is the + // no-provider case — a run that silently changes nothing reads as a broken + // button, so it has to say why. + it('says so rather than generating from an empty document', async () => { + renderSection({}, { defaultOpen: true }); + + fireEvent.click( + screen.getByRole('button', { name: 'Draft from my document' }), + ); + + await waitFor(() => { + expect(document.body.textContent).toContain('Nothing to read yet'); + }); + expect(requestBriefProposal).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/briefSection/index.tsx b/frontend/src/components/briefSection/index.tsx index 9132ed22..65e8d265 100644 --- a/frontend/src/components/briefSection/index.tsx +++ b/frontend/src/components/briefSection/index.tsx @@ -11,10 +11,24 @@ * textareas is most of a screen. Revise, where the brief is step 1 of a * deliberate flow, opens it by default; elsewhere it sits as one summary line * naming which fields are set, until the writer wants it. + * + * ## Drafting from the document + * + * A blank brief is the common case, and the writer has already written the + * evidence for it — the draft. "Draft from my document" asks for candidate + * wording per field (`api/briefProposal`) and renders each one as a provisional + * card the writer keeps or throws away. Nothing a candidate says reaches the + * document until they press Use this; see `docs/design/co-created-brief.md`. */ -import { useRef, useState } from 'react'; +import { useContext, useEffect, useRef, useState } from 'react'; import { AiOutlineRight } from 'react-icons/ai'; +import { requestBriefProposal } from '@/api/briefProposal'; +import { + describeGenerationError, + type GenerationErrorInfo, +} from '@/api/errors'; import { docBriefLog, type LogPage } from '@/api/logging'; +import { ErrorNotice, GenerationErrorNotice } from '@/components/errorNotice'; import { DOC_BRIEF_FIELDS, DOC_BRIEF_LABELS, @@ -22,6 +36,7 @@ import { filledBriefFields, useDocBrief, } from '@/contexts/docBriefContext'; +import { EditorContext } from '@/contexts/editorContext'; import { useLog } from '@/hooks/useLog'; import classes from './styles.module.css'; @@ -46,6 +61,19 @@ const FIELD_PLACEHOLDERS: Record = { 'e.g. Under 400 words, for the campus newspaper, has to cite the budget report...', }; +/** + * What the last "draft from my document" run produced, beyond the candidates + * themselves (which live in the shared context so they survive collapsing the + * section). `empty` and `emptyDoc` are outcomes the writer has to see: a run + * that quietly changes nothing reads as a broken button. + */ +type ProposalRun = + | { kind: 'idle' } + | { kind: 'running' } + | { kind: 'error'; info: GenerationErrorInfo } + | { kind: 'empty' } + | { kind: 'emptyDoc' }; + export interface BriefSectionProps { /** The page rendering it — for attributing edit events. */ page: LogPage; @@ -60,18 +88,96 @@ export default function BriefSection({ step, defaultOpen = false, }: BriefSectionProps): React.JSX.Element { - const { brief, setField, status } = useDocBrief(); + const { + brief, + setField, + status, + proposals, + setProposals, + acceptProposal, + dismissProposal, + } = useDocBrief(); + const editorAPI = useContext(EditorContext); const log = useLog(); const [isOpen, setIsOpen] = useState(defaultOpen); + const [run, setRun] = useState({ kind: 'idle' }); /** * What the focused field held when the writer entered it. An event per * keystroke would be noise and an event per blur would count every field * they merely tabbed through, so one is emitted only when the text changed. */ const valueOnFocusRef = useRef(''); + const proposalControllerRef = useRef(null); + /** + * Read at request time rather than tracked, so the request always uses the + * brief as it stands without rebuilding the handler on every keystroke. + */ + const briefRef = useRef(brief); + briefRef.current = brief; + + useEffect(() => { + return () => { + // Stop an in-flight proposal so it can't set state after unmount. + proposalControllerRef.current?.abort(); + }; + }, []); const filled = filledBriefFields(brief); + async function draftFromDocument() { + proposalControllerRef.current?.abort(); + const controller = new AbortController(); + proposalControllerRef.current = controller; + + setRun({ kind: 'running' }); + + try { + // Pulled here rather than tracked continuously: on the Google Docs + // surface reading the document is an Apps Script round-trip, and the + // page already holds its own copy for its own requests. + const docContext = await editorAPI.getDocContext(); + + if ( + docContext.beforeCursor.length === 0 && + docContext.selectedText.length === 0 && + docContext.afterCursor.length === 0 + ) { + setRun({ kind: 'emptyDoc' }); + return; + } + + docBriefLog.proposalRequested(log, page, { docContext }); + + const proposed = await requestBriefProposal({ + docContext, + brief: briefRef.current, + abortSignal: controller.signal, + }); + + const fields = Object.keys(proposed); + docBriefLog.proposalReceived(log, page, { + fields, + result: JSON.stringify(proposed), + }); + + setProposals(proposed); + setRun(fields.length === 0 ? { kind: 'empty' } : { kind: 'idle' }); + } catch (error) { + if (controller.signal.aborted) return; + const info = describeGenerationError(error); + console.error('Could not draft a brief from the document:', error); + setRun({ kind: 'error', info }); + docBriefLog.proposalError(log, page, { + error: info.detail, + code: info.code, + }); + } finally { + if (proposalControllerRef.current === controller) { + proposalControllerRef.current = null; + } + } + } + // Collapsed, the header still reports which fields are set, so a writer on // Chat or Draft can see a brief is in effect without giving up the space to // three textareas they aren't editing. @@ -110,50 +216,157 @@ export default function BriefSection({ {isOpen ? ( <> - {DOC_BRIEF_FIELDS.map((field) => ( -
-
- -
- {FIELD_HINTS[field]} +
+ + Not sure yet? Your draft already says a lot of this. + + +
+ + {DOC_BRIEF_FIELDS.map((field) => { + const proposal = proposals[field]; + return ( +
+
+ +
+ {FIELD_HINTS[field]} +
-
-