From 29b6c4bd4d4228add0de3b4cc7e1cbbc46137820 Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Tue, 20 Oct 2026 23:26:23 +0200 Subject: [PATCH 1/2] Settle the folded search lines on opencode's form Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- docs/decisions.md | 8 ++++---- docs/product/mvp.md | 2 +- docs/product/prd.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 671e94c..70282a9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ PRD Assistant is a web app being built to turn research notes into a product req ## Status - **Planning:** done. The [product description](docs/product/prd.md) and the [scope](docs/product/mvp.md) are approved. -- **Build:** in progress. Six fictional interviews are split into passages, embedded and stored in Postgres, and listed on a transcripts page. On the home page, a streaming chat drafts the PRD with you, section by section, from the interviews: the model decides when to search them, and each search shows as a line that opens to its passages. The model saves each section it drafts, and the app's own code checks every quote before you see it. Beside the chat, the PRD builds up as a document: each statement names its sources, a click on a name opens the quoted words in the full answer, and whatever the check objects to is written out under the statement. The home page opens on a short intro that makes you the product manager, and its one button drafts the first section; once that's saved, a suggested message invites you to try to break it. +- **Build:** in progress. Six fictional interviews are split into passages, embedded and stored in Postgres, and listed on a transcripts page. On the home page, a streaming chat drafts the PRD with you, section by section, from the interviews: the model decides when to search them, and each search shows as one line naming whose passages came back, with searches in a row folded into one. The model saves each section it drafts, and the app's own code checks every quote before you see it. Beside the chat, the PRD builds up as a document: each statement names its sources, a click on a name opens the quoted words in the full answer, and whatever the check objects to is written out under the statement. The home page opens on a short intro that makes you the product manager, and its one button drafts the first section; once that's saved, a suggested message invites you to try to break it. - **Demo:** none yet. ## How it works @@ -26,7 +26,7 @@ Locally and in CI, a fake embedder stands in for Cloudflare. It counts shared wo The chat runs on the [Vercel AI SDK](https://ai-sdk.dev). The browser's `useChat` posts the conversation to [`/api/chat`](src/app/api/chat/route.ts), and the server streams the answer back ([`chat.ts`](src/server/chat.ts)). Each model reply arrives whole, though: streamed, Cloudflare sends gpt-oss's search requests as reasoning text without the tool's name, so the app asks for whole replies and passes each one on as a single piece ([`cloudflare.ts`](src/server/ai/cloudflare.ts)). The search line still appears before the answer. -1. **Tool calling.** `streamText` runs OpenAI's open-weight `gpt-oss-120b` on Cloudflare Workers AI, through its OpenAI-compatible endpoint, with two tools: `searchNotes`, and `saveSection` for the citation check below. The model decides when to search and what for; the app's code runs the search above and hands back the five closest passages. The model sees each passage's interviewee, question and answer. The browser gets the same passages to show, and each search appears as one line, such as "closest passages from Maya, Henk and Daan", that opens to them. After three searches the app stops offering the search, so the model has to save or write: asked for a section, gpt-oss kept searching until it ran out of model calls. Once the section is saved, it offers no tools at all, so the model replies, and the last call of an answer never carries tools. Each call may write up to 4,096 tokens, reasoning included; Cloudflare's default of 256 cut drafts off. +1. **Tool calling.** `streamText` runs OpenAI's open-weight `gpt-oss-120b` on Cloudflare Workers AI, through its OpenAI-compatible endpoint, with two tools: `searchNotes`, and `saveSection` for the citation check below. The model decides when to search and what for; the app's code runs the search above and hands back the five closest passages. The model sees each passage's interviewee, question and answer. The browser gets the same passages, and each search appears as one line naming whose they are, such as "Search “offline use” · Maya, Henk, Daan". After three searches the app stops offering the search, so the model has to save or write: asked for a section, gpt-oss kept searching until it ran out of model calls. Once the section is saved, it offers no tools at all, so the model replies, and the last call of an answer never carries tools. Each call may write up to 4,096 tokens, reasoning included; Cloudflare's default of 256 cut drafts off. 2. **Failures stay visible.** Whatever breaks, the provider before its first word, the provider partway through, or the connection, the stream ends with a plain error, and the cause is logged on the server. So does an answer the model ends without writing anything, or one cut off at its output limit. An answer that broke off stays on screen marked as unfinished. "Try again" asks again, and the unfinished answer never goes back to the model as if it were whole. Two refusals from Cloudflare get their own message. Once the day's free allocation is used up, the app doesn't retry: it hides the message box and says when the allocation resets (00:00 UTC, also in the visitor's own time), and the chat and the PRD stay readable. When Cloudflare is busy or at its per-minute limit, the app retries twice within a few seconds, then says the demo is busy and to try again in a minute. 3. **Token awareness.** The server logs the tokens every answer used, summed over its model calls, including answers that broke off. The model reasons before it answers; that reasoning stays on the server and never goes back to the model with later messages. Searches from earlier answers go back to the model as passage ids and interviewees only, so a long conversation doesn't send every passage it ever found with each new message; to quote one again, the model searches again. 4. **The instructions.** The system prompt is the PRD prompt from my [AI Product Development Toolkit](https://github.com/TechNomadCode/AI-Product-Development-Toolkit), adapted and versioned in [`system-prompt.ts`](src/server/prompts/system-prompt.ts). It keeps the template's role, its check-in before each new section and its nine sections. Where the template asks questions before writing anything, the assistant drafts first from the interviews and asks only what they don't say. On top come the traceability rules of the toolkit's research prompt (every statement traces to a passage or to your own message, interpretations are marked as such, unsupported assumptions become open questions), saving each section for the check, the citation format, how to show a contradiction, and a rule never to invent. Every model call sends the whole prompt, about 1,775 tokens. Any change to the text needs a new version: a test compares the text's fingerprint with the one recorded for its version. diff --git a/docs/decisions.md b/docs/decisions.md index 0c4d4bf..786e117 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -3,17 +3,18 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · consequence`. When a decision changes, replace its line; Git keeps the old one. Agents read the section their task touches. ## Design -- 2026-10-20 · The interface polish, slices 10b to 20 (the owner, 2026-10-20), from a study of the source of opencode's desktop app (https://github.com/anomalyco/opencode, MIT licence; `packages/app`, `packages/session-ui` and `packages/ui` at commit 50c5fce on branch `dev`, read 2026-10-20): its spacing and layout, a moving line while the AI works, a Stop button, the PRD showing what changed, "Jump to latest", an answer's searches folded into one line (its form still to approve, under *Deferred*), words fading in, copying a section, the Inter typeface, opencode's tables and a sign that the model is thinking. Its ideas, not its code or its looks, inside the colour and shape rules below; CSS and the React the app already has, no animation library (opencode's springs use `motion`, and adding it needs the owner's OK); every animation stops under `prefers-reduced-motion`. The model's thinking shows as "Thinking…" and "Thought for 8s", never its words: gpt-oss's chain of thought isn't held to the safety standards of its final answers, and OpenAI recommends hiding it from end users (https://github.com/openai/harmony/blob/main/docs/format.md, read 2026-10-20 through Context7); since the chat model gives whole replies (slice 8a), the sign is the browser's own wait for each reply, and nothing in `src/server` changes (slice 20) · The owner wants the demo to feel as finished as opencode's app. Left out: automatic retries, which spend the shared allocation, and sounds, themes, prompt history and opencode's timeline settings, which serve long coding sessions rather than a short demo +- 2026-10-20 · The interface polish, slices 10b to 20 (the owner, 2026-10-20), from a study of the source of opencode's desktop app (https://github.com/anomalyco/opencode, MIT licence; `packages/app`, `packages/session-ui` and `packages/ui` at commit 50c5fce on branch `dev`, read 2026-10-20): its spacing and layout, a moving line while the AI works, a Stop button, the PRD showing what changed, "Jump to latest", an answer's searches folded into one line (its form below), words fading in, copying a section, the Inter typeface, opencode's tables and a sign that the model is thinking. Its ideas, not its code or its looks, inside the colour and shape rules below; CSS and the React the app already has, no animation library (opencode's springs use `motion`, and adding it needs the owner's OK); every animation stops under `prefers-reduced-motion`. The model's thinking shows as "Thinking…" and "Thought for 8s", never its words: gpt-oss's chain of thought isn't held to the safety standards of its final answers, and OpenAI recommends hiding it from end users (https://github.com/openai/harmony/blob/main/docs/format.md, read 2026-10-20 through Context7); since the chat model gives whole replies (slice 8a), the sign is the browser's own wait for each reply, and nothing in `src/server` changes (slice 20) · The owner wants the demo to feel as finished as opencode's app. Left out: automatic retries, which spend the shared allocation, and sounds, themes, prompt history and opencode's timeline settings, which serve long coding sessions rather than a short demo +- 2026-10-20 · An answer's searches show the way opencode's desktop app shows its search tools, as closely as the colour rules allow (the owner, 2026-10-20: full opencode compliance here, no extra components; `CurrentContextToolGroup` in `packages/session-ui/src/tools/tool-renderer.tsx`, `context-tool-group` in `packages/session-ui/src/components/message-part.css` and `packages/ui/src/components/collapsible.css`, at 50c5fce, read 2026-10-20): each search is a plain line that doesn't open, "Search" in medium weight, then the query and whose passages came back in grey, at 13px; two or more searches in a row fold into one line, "Used 3 searches", whose count rolls up as each search starts and which opens to their plain lines, 28px rows 8px apart, without indent or side line; the line's mark is a small triangle after its text, pointing right when closed and down when open, turning in 0.15s; the lines slide open in about 0.35s, without bounce; no time on the line; all movement stops under reduced motion. Unlike opencode's, whose mark shows only on hover, the mark shows faintly at rest, as a phone has no hover (the owner) · The owner found a cascade of opening lines bloated: opencode opens one layer, and its search lines never open. So the chat no longer shows a search's passages. The visitor checks the output in the PRD: each statement's sources open the pop-over with the quote, and the check's tally and objections show under it; whether the search finds the right passages is the measurement's question, not the visitor's. Replaces each step as one line that opens its passages (2026-10-17, 2026-10-18). Not taken: a line in the pop-over naming the search that found a quote (an idea for later, if the owner wants it), a passage count on each line like opencode's "(12 matches)", since the names say more to a visitor - 2026-10-20 · The workspace's spacing and layout follow opencode's desktop app where it fits this app (slice 10b has the figures): a chat column of steady width beside a PRD pane that takes the rest, a page wider than 1280px, slimmer page chrome around the panes, a question closer to its own answer than to the turn before, tighter steps, list items further apart, and a message box that grows with its text · On a 1440px-wide screen the PRD's page gets 625px of its decided 42rem, the chat puts as much space between a question and its own answer as between turns, and the header, footer and padding take about 150px of the height. Not taken: opencode's 14px body text (its sessions are dense code; this chat is for reading, and its message box needs 16px so iOS doesn't zoom), its rounded panels (the PRD is a document page, not a card: 2026-10-18), its shadowed message box without a border (this one keeps its border, for contrast), and its reading column of up to 744px, which on a laptop would leave the PRD too little room beside it - 2026-10-20 · A table in the model's reply shows as a table, the way opencode's desktop app shows one (`packages/session-ui/src/components/markdown.css`, *Tables*, read 2026-10-20): the header's cells in medium weight over a stronger hairline, rows apart by faint hairlines, no vertical lines, cells top-aligned with even padding, and no pipe or Markdown mark shown as typed; a table wider than the chat scrolls sideways inside the message, reachable by keyboard. An HTML line break (`
`, `
` or `
`, in any letter case) becomes a line break, in a table's cells and anywhere else in the model's text except inline code, and one at a cell's edge is dropped; no other HTML is interpreted. Built with slice 19; until then the chat shows a labelled card per row · The owner prefers opencode's tables, after the study of its desktop app (the owner, 2026-10-20). gpt-oss-120b writes tables whose cells hold several points separated by `
` (staging, 2026-10-19), so the line-break rule stays. Replaces the labelled card per row (chosen 2026-10-20), which replaced one line per row (2026-10-19) - 2026-10-18 · In the PRD, confirmed quotes aren't written out: each statement ends with a small grey marker naming its sources, such as "Maya, Daan" or "You, in this chat", and clicking a name opens the pop-over with the quote highlighted in the full answer; only what the code objects to is written out under the statement: an unverified quote with its words and the reason, "No quote backs this statement." and "Your decision, not from the interviews."; the explanation above the first section says so · A PRD with a hundred visible quotes reads as a log and nobody reads them, while problems still show where they are. Replaces "every quote sits next to its statement" (Data, 2026-10-17); the quality check prints the statements at fault the same way - 2026-10-20 · The Markdown download reads like the PRD on screen: each statement ends with its sources by name, each name with a footnote that holds its quotes and where they come from, and what the code objects to is written out under the statement (the owner, slice 10) · Keeps the document readable, as on screen, while every quote stays in the file. Settles the item deferred to slice 10 on 2026-10-18 -- 2026-10-18 · The PRD beside the chat, after the owner's review of the second mockup against a competitor's product: the split, the one-line steps, the pop-over with the researcher's question above the answer, and the copy stay; the PRD pane is a document, not cards, on a slightly lighter near-black page with no border (`#141414` on the pane's black), at most 42rem of text wide; it opens with a title block: the product's name at about 26px, one grey line "Product requirements document · Draft · 2 of 9 sections · From six household interviews", a hairline and the explanation; all nine sections show from the start as an outline, the ones not drafted yet as grey headings; a drafted section has an 18px heading with its tally ("11 of 12 quotes confirmed") in grey beside it, then its statements as a bulleted list; sizes set once: statements 16px white, quoted words 15–16px, captions 13–14px grey, step lines 14px, nothing smaller than 13px; gaps: 2–4px from a statement to what's written under it, 10px between list items, 32px above each section heading and 10px below it; above the page the pane holds only "Start over"; what the code says has one style, plain white 500-weight lines at caption size, and nothing in the pane has a border at rest except the one button; the pop-over is at most about 520px wide and anchored to what was clicked on desktop, with its anchor rules inside `@supports` so browsers without anchor positioning (iPhones before iOS 26 among them) get a usable centred box; on phones it isn't anchored: nearly full width, scrolling inside, closed by a tap outside; the PRD pane never scrolls by itself; the "Saved" line in the chat ends in "See it", which scrolls to that section, in the pane on a laptop or down the page on a phone, and no longer opens an earlier version; the "Searched" line still opens to its passages; the chat follows the newest message unless the visitor has scrolled up; the message box uses 16px text, so iOS doesn't zoom; phones stack, with no tabs · Replaces the first two proposals: section cards that fold, a boxed explanation and labels as chips. The owner's final changes, after the third mockup still read as loose sentences rather than a document: the title block, the outline, bulleted statements with the tighter gaps, and the page (they replace the pane header "Your PRD · 1 of 9 sections", a hairline between sections and 20–24px between statements). The check-in button's states and the rest are in `docs/plan.md`, slice 6 +- 2026-10-18 · The PRD beside the chat, after the owner's review of the second mockup against a competitor's product: the split, the one-line steps, the pop-over with the researcher's question above the answer, and the copy stay; the PRD pane is a document, not cards, on a slightly lighter near-black page with no border (`#141414` on the pane's black), at most 42rem of text wide; it opens with a title block: the product's name at about 26px, one grey line "Product requirements document · Draft · 2 of 9 sections · From six household interviews", a hairline and the explanation; all nine sections show from the start as an outline, the ones not drafted yet as grey headings; a drafted section has an 18px heading with its tally ("11 of 12 quotes confirmed") in grey beside it, then its statements as a bulleted list; sizes set once: statements 16px white, quoted words 15–16px, captions 13–14px grey, step lines 14px, nothing smaller than 13px; gaps: 2–4px from a statement to what's written under it, 10px between list items, 32px above each section heading and 10px below it; above the page the pane holds only "Start over"; what the code says has one style, plain white 500-weight lines at caption size, and nothing in the pane has a border at rest except the one button; the pop-over is at most about 520px wide and anchored to what was clicked on desktop, with its anchor rules inside `@supports` so browsers without anchor positioning (iPhones before iOS 26 among them) get a usable centred box; on phones it isn't anchored: nearly full width, scrolling inside, closed by a tap outside; the PRD pane never scrolls by itself; the "Saved" line in the chat ends in "See it", which scrolls to that section, in the pane on a laptop or down the page on a phone, and no longer opens an earlier version; the search lines as decided on 2026-10-20; the chat follows the newest message unless the visitor has scrolled up; the message box uses 16px text, so iOS doesn't zoom; phones stack, with no tabs · Replaces the first two proposals: section cards that fold, a boxed explanation and labels as chips. The owner's final changes, after the third mockup still read as loose sentences rather than a document: the title block, the outline, bulleted statements with the tighter gaps, and the page (they replace the pane header "Your PRD · 1 of 9 sections", a hairline between sections and 20–24px between statements). The check-in button's states and the rest are in `docs/plan.md`, slice 6 - 2026-10-18 · Three fixes in code, never in the prompt, built with slice 6: any statement whose only quotes are the visitor's own chat messages shows as the visitor's decision, not from the interviews, whatever the model wrote; under each saved section a confirm button does the check-in; Markdown marks such as `**` in the model's text are rendered or stripped, never shown as typed · On staging (2026-10-18), gpt-oss-120b wrote "Some households expressed interest" for the visitor's grocery delivery claim, ended a draft without checking in, and showed `**` as typed - 2026-10-19 · The transcripts page stays as it is, the plain list from slice 1, and the intro's quieter link, "Read the full interviews", leads there; the intro makes sense on its own, and nothing a reviewer needs is only on the transcripts page (the owner, 2026-10-19) · Settles the open question of slice 7 and the PRD's Design Considerations: quotes open in the pop-over, and the full interviews stay one click away, from the intro and from the header's "Transcripts" - 2026-10-19 · The intro, as built with slice 7 inside the decided design: the heading "You're the product manager of a shared grocery list app", two short paragraphs, the filled white button "Draft the first section", which sends `FIRST_SECTION_REQUEST` from `src/lib/suggestions.ts`, and under it, quieter, the link "Read the full interviews" and the control "Or ask your own question first", which opens the empty chat without sending anything, focus in the message box; the chat and the PRD show from then on, and whenever the conversation holds messages (`conversationStarted` in `src/features/workspace/disclosure.ts`, for slice 10 to reuse); when the intro's request fails before a section is saved, the intro comes back with "The first section wasn't drafted: something went wrong before the assistant could finish. Try again in a moment." above its button, the conversation stays empty, and the message box keeps what the visitor typed meanwhile; once a section is saved, a failure stays in the chat with "Try again", like any answer that broke off, and the PRD keeps the section; the link for engineers sits in every page's footer, "How the search and the check work" (the README's `#how-it-works`) beside "Source code on GitHub", so the intro and the chat both have it; once a section is saved, the suggestion sits above the message box, "Now try to break it. Send a claim that nobody in the interviews made:" over an outline button with `GROCERY_DELIVERY_SUGGESTION`'s words, which sends them in one click, moves focus to the message box, waits while the assistant works and is gone once the conversation holds that message; focus moves to the chat's heading for screen readers, "The chat and your PRD", after the button, and to the intro's heading after "Start over" or a failed first request · A section the check already passed is never thrown away, nothing the visitor typed is lost, and focus never falls to the page. The owner decided on 2026-10-20 to keep "Or ask your own question first", built with slice 7 as a proposal: it keeps a question-first visit possible, as the chat's placeholder invites, and it is how the end-to-end tests reach an empty chat (`openChat` in `tests/e2e/chat.spec.ts` and `tests/e2e/prd.spec.ts`) - 2026-10-17 · Desktop first; on a phone the chat and the PRD stack on one page, usable not polished; the home page opens on a short intro screen, then shows the chat with the PRD beside it; quotes open their source in a pop-over, with the researcher's question above the answer; no separate UX document · The PRD's Design section is the design brief -- 2026-10-17 · The demo explains itself through progressive disclosure: the intro screen makes the visitor the product manager, with the researcher's six interviews as finished records, and its one button drafts the first section; the AI drafts before asking and asks only what the transcripts don't say, explaining once that the answer becomes the visitor's own decision; each step shows as one line that opens its passages; quotes show their source by name and the check's result, explained once next to the first section; a suggestion to try to break it appears only after the first section, with a claim nothing in the interviews comes close to (grocery delivery), while the dark mode claim, which Henk's words come close to, stays in the quality check as the harder test; a quiet link leads engineers to the README and the code · Replaces the opening summary, the upfront gap questions and the suggested first messages; the quality check tests the new flow +- 2026-10-17 · The demo explains itself through progressive disclosure: the intro screen makes the visitor the product manager, with the researcher's six interviews as finished records, and its one button drafts the first section; the AI drafts before asking and asks only what the transcripts don't say, explaining once that the answer becomes the visitor's own decision; each step shows as one line (searches in a row folded: 2026-10-20); quotes show their source by name and the check's result, explained once next to the first section; a suggestion to try to break it appears only after the first section, with a claim nothing in the interviews comes close to (grocery delivery), while the dark mode claim, which Henk's words come close to, stays in the quality check as the harder test; a quiet link leads engineers to the README and the code · Replaces the opening summary, the upfront gap questions and the suggested first messages; the quality check tests the new flow - 2026-10-17 · Where the PRD and the plan name a placement or a control (the PRD beside the chat, a pop-over, one line per step, a line under the chat box, an explanation next to the first section, the transcripts page), they state the purpose, not a fixed layout; the piece of work that builds it may propose another form that serves the same purpose, and the owner decides before it's built; tests check the purpose (content, behaviour, accessibility), not the placement; the colour rules below stay fixed · Keeps the UI open until each piece is built; whether the transcripts page stays is decided with the intro screen - 2026-10-17 · Black and white colour scheme: black dominant (main backgrounds and large surfaces), white in support (text, lines, accents). Not allowed: cream or off-white backgrounds, italic accent words in headlines, numbered "01/02/03" section labels, monospace labels, pill-shaped buttons · Feeds the PRD's Design Considerations and every UI piece @@ -78,5 +79,4 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque - 2026-10-17 · A short README section, "If this became a product", written from the PRD's later list: each item says what it adds and which part of the existing code it builds on; text only, about an hour, no new features · do in stage 5 · affects the README - 2026-10-19 · What "the grocery delivery request has to pass before launch" means once it's measured over several runs (for example 5 of 5, or at least 4 of 5), and what happens after a miss · decide before launch, from the baseline · affects the launch and the README - 2026-10-19 · How the README's tokens for a full PRD are measured: one chained run of nine sections with their check-ins (it may take a whole day's allocation or more, a guess), or tokens per request · decide before slice 8, whose rate limit size needs the same figure · affects the README and the rate limit -- 2026-10-20 · Whether an answer's searches fold into one line, such as "Searched the interviews 4 times · 12s", replacing one line per step (Design, 2026-10-18), and the line's exact form · decide before slice 15, from a sketch · affects the chat's steps and their tests - 2026-10-20 · Whether the wait for each of the model's replies reads "Thought for 8s" (the recommendation: gpt-oss reasons before every reply) or "Took 8s" (the last reply's wait also covers the model writing its text) · decide before slice 20 · affects the chat's lines and their tests diff --git a/docs/product/mvp.md b/docs/product/mvp.md index 3610533..d9b64e1 100644 --- a/docs/product/mvp.md +++ b/docs/product/mvp.md @@ -42,7 +42,7 @@ An engineer looking at an AI project can't tell whether its output is grounded o - Streaming chat after the intro screen: - drafting before asking, with questions only where the transcripts are silent - nine sections drafted one at a time and confirmed by the user - - each step shown as one line that opens its passages + - each step shown as one line, searches in a row folded into one - the suggestion to try to break it, after the first section - the demo notice under the chat box, and the link for engineers diff --git a/docs/product/prd.md b/docs/product/prd.md index c9cdced..e88fa96 100644 --- a/docs/product/prd.md +++ b/docs/product/prd.md @@ -212,7 +212,7 @@ Product type: a web application built with Next.js, Supabase (Postgres with pgve **Progressive disclosure** - Each moment shows only what the visitor needs right then. Details open when they ask for them. -- Each step the AI takes is one line that opens to show its passages. +- Each step the AI takes is one line; searches in a row fold into one line that opens to them, as in opencode’s desktop app. - Confirmed quotes open on a click; only what the check objects to is written out. How to read this is explained once, above the first section. - The suggestion to try to break it appears only after the first section. - The link for engineers stays quiet and leads to the README's explanation and the code. From 41d15d8d618d5c6322b991bb9e1ef18882aa61c9 Mon Sep 17 00:00:00 2001 From: TechNomadCode Date: Tue, 20 Oct 2026 23:26:35 +0200 Subject: [PATCH 2/2] Fold an answer's searches into one line, as opencode does Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- KNOWN-ISSUES.md | 1 + docs/plan.md | 23 ++-- src/app/globals.css | 36 ++++++ src/components/rolling-number.tsx | 35 +++++ src/features/AGENTS.md | 3 +- src/features/chat/conversation.tsx | 57 +++++++- src/features/chat/search-run.tsx | 77 +++++++++++ src/features/chat/search-step.tsx | 112 ++++++++-------- src/features/prd/prd-document.tsx | 33 +---- src/server/ai/fake-chat-model.ts | 19 ++- tests/AGENTS.md | 2 +- tests/e2e/chat.spec.ts | 90 ++++++++++--- tests/e2e/layout.spec.ts | 8 +- tests/unit/conversation.test.tsx | 179 ++++++++++++++++++++------ tests/unit/fake-chat-model.test.ts | 27 ++++ tests/unit/kept-conversation.test.tsx | 3 +- tests/unit/step-line.ts | 14 ++ tests/unit/workspace.test.tsx | 9 +- 19 files changed, 552 insertions(+), 178 deletions(-) create mode 100644 src/components/rolling-number.tsx create mode 100644 src/features/chat/search-run.tsx create mode 100644 tests/unit/step-line.ts diff --git a/CLAUDE.md b/CLAUDE.md index 978f442..330c03f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ @AGENTS.md ## Claude Code only -- **Desktop app:** `.claude/launch.json` runs the app for the Browser pane: `app` with the fakes, `app-real` with the chat on the real model, which spends neurons (`AGENTS.md`, Boundaries), both on port 3000, and `app-build` with the fakes on port 3200. After a UI change, check the page there once before you finish: with `app-build` in a build-lane worktree, with `app` otherwise (`docs/plan.md`, Now, the lanes). Never `app-real` outside the measurement lane. A session that started in the main checkout and made its worktree afterwards gets `app-build` started in the main checkout, on its code (its log lists `.env.development.local`, which a worktree hasn't got): run `npm run dev -- --port 3200` in the background from the worktree instead, open `http://localhost:3200` with `preview_start`'s `url`, and stop the server afterwards (found 2026-10-20). Stopping that background command can leave Next's own server process listening on 3200: check the port, and end that process too. That `next dev` also writes its block into `AGENTS.md` (KI-8): undo it before you commit. +- **Desktop app:** `.claude/launch.json` runs the app for the Browser pane: `app` with the fakes, `app-real` with the chat on the real model, which spends neurons (`AGENTS.md`, Boundaries), both on port 3000, and `app-build` with the fakes on port 3200. After a UI change, check the page there once before you finish: with `app-build` in a build-lane worktree, with `app` otherwise (`docs/plan.md`, Now, the lanes). Never `app-real` outside the measurement lane. `preview_start` runs `app-build` in the folder the session started in: in a worktree entered later, it served the main checkout’s code (found 2026-10-20). Check the server’s folder, or start `npm run dev -- --port 3200` from the worktree and open its address with `preview_start`’s `url`. A session that started in the main checkout and made its worktree afterwards gets `app-build` started in the main checkout, on its code (its log lists `.env.development.local`, which a worktree hasn't got): run `npm run dev -- --port 3200` in the background from the worktree instead, open `http://localhost:3200` with `preview_start`'s `url`, and stop the server afterwards (found 2026-10-20). Stopping that background command can leave Next's own server process listening on 3200: check the port, and end that process too. That `next dev` also writes its block into `AGENTS.md` (KI-8): undo it before you commit. - **Worktree sessions** (desktop worktree option or `claude --worktree`): run `npm ci` first. `.worktreeinclude` copies `.env.local` in for you, not `.env.development.local`, so `npm run dev:real` works in the main checkout only, unless the owner copies that file in by hand. A worktree made with `git worktree add` and entered afterwards gets no copy: run `npm run env:local` with the local stack up, or the chat refuses every request for want of `AI_PROVIDER` (found 2026-10-20, as 24 failed end-to-end tests). diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index a363b57..14b318c 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -14,6 +14,7 @@ What happens and how to reproduce it. What's known about the cause, with guesses **Found:** 2026-10-20 · **Severity:** low In one run during slice 11, and not on the run before or after: `tests/e2e/prd.spec.ts`, "reads Drafting while it works, and comes back with a line beside it when the answer fails" (since slice 13, "says Drafting beside the next section while it works, …"), waited past its 5 seconds with the fake model's answer stuck after the saved section. It passed five times in a row on its own and in the full suites afterwards. A guess: it waits on the fake model's timing, which six workers on a busy machine stretch. Fixing it means finding what it waits on and waiting for that condition instead; if it fails again, note the run here. (The unit test that failed with it was a test reading storage too early, fixed with slice 12; `tests/AGENTS.md`, *Writing tests*.) Again during slice 14, in two full runs of five: 2 and then 3 tests (`prd.spec.ts`'s pop-over tests, "Start over asks once…", "follows the newest message…", `quota.spec.ts`'s "the chat and the PRD so far stay readable…"), each in `draftFirstSection` or its like, waiting past 5 seconds for the first draft's answer to end while the fake model was still streaming its reply. The same tests passed twice each on their own, and the three full runs after them passed, one of them without slice 14's change to the chat. So the wait is the fake model's answer under six workers, not what the page does. +Again during slice 15 (2026-10-20), in full runs back to back on the finished code: one failed 2 tests (not recorded), one passed all 59, one failed 4: `layout.spec.ts`'s three Jump to latest tests and `prd.spec.ts`'s "follows the newest message…", each waiting past 5 seconds for a first draft's answer while the fake model was still at a search or the check. One of them, "offers Jump to latest once opened searches push…", now drafts after three searches (`FAKE_SEARCH_AGAIN`), a longer answer, so it has less room in that wait. ## KI-8 · `next dev` adds its own block to `AGENTS.md` **Found:** 2026-10-20 · **Severity:** low diff --git a/docs/plan.md b/docs/plan.md index e70bb60..b90aaf9 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -20,12 +20,12 @@ ### Build lane - **Where:** its own worktree per piece, on a branch from an up-to-date `main`. If the session isn't in a worktree yet (`git worktree list` shows its folder as the main checkout, not under `.claude/worktrees/`), it creates one before touching anything, and never checks out a branch in the main folder, where the measurement lane works. Claude Code's worktree option puts it under `.claude/worktrees/`; run `npm ci` there first. A worktree made with `git worktree add` gets no `.env.local`: run `npm run env:local` there, or the end-to-end tests find no interviews. Its browser check uses `app-build` in `.claude/launch.json` (port 3200), never `app` or `app-real`, which are the measurement lane's on port 3000. It never runs `measure`, `measure:fake` or `dev:real`, never edits the measurement lane's files above, and changes code in `src/server` only where its piece needs it, never what the model receives (listed above); its pull request says which `src/server` files it changed and why. -- **Doing:** slice 14 is done on `feat/jump-to-latest` (`.claude/worktrees/slice-14-jump`), from `main` at 759266a (slice 13's merge), waiting for the owner's push and merge. Local checks on 2026-10-20: `verify` (447 unit tests, 6 new in `tests/unit/workspace.test.tsx`) and `test:e2e` (57, 3 new in `tests/e2e/layout.spec.ts`), all passed; two earlier full runs failed 2 and 3 tests on the fake model's timing (KI-7), then three passed. The page checked at 1440px on a dev server from the worktree with the fakes: the button centred at the pane's bottom over the text, square corners, gone after the smooth scroll with focus in the message box, and shown after opening passages in a chat that already scrolled. It changes nothing in `src/server` or `src/lib`, so the measurement lane needn't wait for it. +- **Doing:** slice 15 is done on `feat/fold-search-steps` (`.claude/worktrees/slice-15-fold`), from `main` at ad27b3d (slice 14's merge), waiting for the owner's push and merge. Its form follows opencode's search tools, approved by the owner on 2026-10-20 (Design, 2026-10-20). Local checks on 2026-10-20: `verify` (454 unit tests) passed, and `test:e2e` (59, of which 2 new in `tests/e2e/chat.spec.ts`) passed in one full run on the finished code; two others failed 2 and 4 tests waiting on the fake model's answers (KI-7). The page checked at 1440px on a dev server from the worktree with the fakes: "Used 3 searches" with its faint triangle, the rows 13px, 28px high and 36px apart, cut off with an ellipsis, the slide and the turn interpolating, and the closed rows hidden and inert. It changes `src/server/ai/fake-chat-model.ts` (a new phrase, `FAKE_SEARCH_AGAIN`, so the tests get searches in a row; nothing the real model receives) and `src/features`, `src/components`, `src/app/globals.css`, so the measurement lane needn't wait for it. - **Next:** 1. Slices 15 to 20 in order, each on its own branch from `main` once the one before is merged. They change only the browser's code (`src/features`, `src/components`, `src/app`'s styles and layout) and its tests, so the measurement lane needn't wait for them, except slice 19 (`src/lib/markdown.ts`, which the model never sees); its pull request says so. None changes `src/server`. Before slice 15, the owner decides its form (*Deferred*). 2. Slice 8, the rate limit, once 9d is done and the owner has decided its legal basis and size (*Deferred*). -- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest). Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token. -- **Waiting on the owner:** pushing `feat/jump-to-latest`, its pull request and the merge; before slice 15, its form (*Deferred*); whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like. +- **Unverified:** on staging, a reload keeping the PRD and the downloaded file (local Chromium only); a stop ending the answer on Cloudflare too, where the call in flight may still be finished and counted; the quota message against Cloudflare's real refusal (the tests replay its reply of 2026-10-20) and whether its per-minute 429 carries a retry header; a source name's pop-over, "Your decision, not from the interviews." under a claim of the visitor's, the table fix and the intro screen; Safari and Firefox (only Chromium ran; without anchor positioning the pop-over is a centred box, by design, and without `field-sizing`, before Safari 26.2 and Firefox 152, the message box keeps its three lines; the moving band, whose fallback is the still grey line; the PRD's highlight and rolling count; Jump to latest; the folded line's slide, which falls back to showing at once). Expect the Security Advisor's lint 0028 on `search_passages` on staging; it's intended (`supabase/AGENTS.md`). Production waits for launch and its Cloudflare token. +- **Waiting on the owner:** pushing `feat/fold-search-steps`, its pull request and the merge; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like. ### Shared rules - One branch per piece, from an up-to-date `main`; the owner pushes it, opens the pull request and merges it (`AGENTS.md`, Branches). After the other lane's merge, rebase an unpushed branch onto `main`, and merge `main` into a pushed one; never rewrite a pushed branch. @@ -103,17 +103,16 @@ ### 13 · Show in the PRD what just changed · risk: normal · status: done (759266a) -### 14 · Jump to latest · risk: normal · status: done (on `feat/jump-to-latest`; its id on `main` goes here after the merge) +### 14 · Jump to latest · risk: normal · status: done (ad27b3d) -### 15 · Fold an answer's steps into one line · risk: normal · status: todo -- **Goal:** an answer that searches several times opens with one line, not a list of steps. +### 15 · Fold an answer's searches into one line, as opencode does · risk: normal · status: done (on `feat/fold-search-steps`; its id on `main` goes here after the merge) +- **Goal:** an answer's searches read like opencode's search tools: plain lines, and one line for a run of them, opening once (Design, 2026-10-20, approved from the sketch that day). - **Acceptance:** - - Before it's built, the owner approves its form from a sketch (*Deferred*). Once approved, it replaces one line per step (Design, 2026-10-18). - - An answer's searches show as one line, such as "Searched the interviews 4 times · 12s", which opens to today's lines, each still opening to its passages. An answer with one search keeps today's line. - - "Saved … · See it" lines are never folded: they stay where they are. - - While the answer works, the line shows the step in progress with slice 11's band, and its count goes up as searches finish. - - The time is how long the answer took, measured in the browser and kept with the conversation, so it outlasts a reload; an answer kept from before this piece shows none. - - Tests that read the "Searched" lines change with it. + - Each search is a plain line that doesn't open: "Search" in medium weight, then the query and, in grey, whose passages came back ("Search “household composition” · Daan, Sanne"), or that none did, that it didn't work (with the reason), or that it was stopped. At 13px, as opencode's. The chat no longer shows the passages. + - Two or more searches in a row fold into one line, "Used 3 searches", a button with `aria-expanded` that opens to their plain lines: rows of 28px, 8px apart, 4px under the folded line, no indent or side line. A text or a "Saved" line between searches starts a new run. "Saved … · See it" lines are never folded. + - The folded line's mark is a small triangle after its text: pointing right and faint when closed, full on hover; pointing down and full when open; it turns in 0.15s. The lines slide open in about 0.35s without bounce. Under reduced motion nothing turns or slides. + - While the answer works, the run's line is the moving line (slice 11's band): the search in progress when alone, the folded line once there are two; its count rolls up as each search starts (`RollingNumber`, slice 13). Nothing is stored apart: the form follows from the conversation, so a kept conversation shows it too. + - CSS and the React the app has, no new dependency. Tests that read the "Searched" lines change with it. ### 16 · New text fades in · risk: normal · status: todo - **Goal:** the assistant's text fades in instead of appearing in a jump. diff --git a/src/app/globals.css b/src/app/globals.css index 7e03dac..9760197 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -86,6 +86,42 @@ body { } } +/* + * The lines a folded line opens to slide open and shut in about 0.35s, like + * opencode's spring without bounce: the one grid row grows from 0fr to 1fr. + * Closed, they're hidden once the slide has ended. With reduced motion they + * show and hide at once. + */ +.fold { + display: grid; + grid-template-rows: 0fr; + visibility: hidden; +} + +.fold[data-open] { + grid-template-rows: 1fr; + visibility: visible; +} + +.fold > * { + min-height: 0; + overflow: hidden; +} + +@media (prefers-reduced-motion: no-preference) { + .fold { + transition: + grid-template-rows 0.35s cubic-bezier(0.22, 1, 0.36, 1), + visibility 0s 0.35s; + } + + .fold[data-open] { + transition: + grid-template-rows 0.35s cubic-bezier(0.22, 1, 0.36, 1), + visibility 0s; + } +} + /* * A PRD section just saved, or saved again with different statements: a soft * highlight behind it that fades to the page in about two seconds, with no diff --git a/src/components/rolling-number.tsx b/src/components/rolling-number.tsx new file mode 100644 index 0000000..7bf325d --- /dev/null +++ b/src/components/rolling-number.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState } from "react"; + +/** + * A number that rolls to its new value: the old one leaves as the new one + * comes in, upwards when it grows. Plain text at rest; with reduced motion + * the CSS changes it at once (`.roll` in `src/app/globals.css`). + */ +export function RollingNumber({ value }: { value: number }) { + const [shown, setShown] = useState(value); + const [from, setFrom] = useState(); + if (value !== shown) { + setShown(value); + setFrom(shown); + } + if (from === undefined) return value; + return ( + from ? "up" : "down"} + > + + {from} + + setFrom(undefined)} + > + {value} + + + ); +} diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 2de3403..9a56a3c 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -7,11 +7,12 @@ - **Desktop layout:** the body takes the viewport height only on pages that hold `[data-workspace]` (`lg:has-[[data-workspace]]:h-dvh` in `src/app/layout.tsx`, Tailwind's `has-*` variant), so the two panes scroll on their own and the transcripts page scrolls as before. On those pages the page is at most 1600px wide (`max-w-workspace`, from `--container-workspace` in `src/app/globals.css`), and the chat's column is `clamp(25rem, 38%, 32.5rem)`: 400px on a 1024px screen, 520px from about 1416px, with the PRD taking the rest (`docs/decisions.md`, Design, 2026-10-20). The header, the main area's padding and the footer take 114px of the height. The header is 45px on every page, not only the workspace, so it doesn't change height when the intro gives way to the chat; the focused skip link fits inside it. `tests/e2e/layout.spec.ts` measures all of it. - **The chat's spacing:** 16px from a question to its answer, or to "Working…", and 32px between turns (`docs/decisions.md`, Design, 2026-10-20), through `data-role` on the conversation's items: a question that got no answer stays 32px from the next one. Inside an answer, 8px between steps in a row and 16px between a step and text either way (`spaceAbove` in `chat/conversation.tsx`, opencode's rule in `packages/session-ui/src/timeline/projection.ts` at 50c5fce, read 2026-10-20), and 24px before a failure notice, in the answer and above the chat's alert. - **The moving line** (slice 11): while an answer is on its way exactly one line moves, the latest step in progress, or "Working…" at the answer's end whenever no step runs and no text arrives. Text at the answer's end counts as arriving even once its part is `done`: a reply's steps come after its text, so trailing text means the answer is finishing, and a "Working…" that flashed there before the stream closed left the chat 48px off its end, so it stopped following when the message box grew (found 2026-10-20 by `tests/e2e/layout.spec.ts`). Nothing moves once the status leaves `submitted`/`streaming` (`activityOf` in `chat/conversation.tsx`). The band is the one shared class `.shimmer` in `src/app/globals.css`, for slices 13, 15 and 20 to reuse: a gradient clipped to the text, only under `prefers-reduced-motion: no-preference` and `forced-colors: none` and where `background-clip: text` is supported, so everywhere else the line is still and grey (MDN, `background-clip`, Accessibility, read 2026-10-20 through Context7). It is decoration: the screen reader's status line in `chat/chat.tsx` is unchanged. jsdom applies no media queries, so the unit tests check which line carries `.shimmer`, and `tests/e2e/chat.spec.ts` checks the animation and the still line with `page.emulateMedia({ reducedMotion })`. +- **The search lines** (slice 15, `chat/search-step.tsx`, `chat/search-run.tsx`): opencode’s form at 50c5fce (`docs/decisions.md`, Design, 2026-10-20). Each search is a plain `

`, never a button: "Search" in medium weight, the query and what came of it in grey, 13px, one line cut off with `truncate` (a failure keeps its whole reason). The dot is `aria-hidden` and an `sr-only` "passages from" goes before the names. Searches in a row (`laidOut` in `chat/conversation.tsx`) fold into `SearchRun`: a button "Used 3 searches" with `aria-expanded`, whose count is the shared `RollingNumber` (`src/components/rolling-number.tsx`, also the PRD’s), then an inline SVG triangle, `-rotate-90` and `opacity-45` at rest, full on hover and turned down when open, through Tailwind’s `group-aria-expanded:`; its lines sit in `.fold` (`src/app/globals.css`), a one-row grid from `0fr` to `1fr` with `visibility` held back until the slide ends, and `inert` while closed, which takes them out of the accessibility tree and the tab order (MDN, `inert`, read 2026-10-20 through Context7). Interpolating the row from `0fr` was checked in Chromium only (2026-10-20); where it doesn’t interpolate, the lines appear at once. Rows are `leading-7`, 8px apart, 4px under the button. While a run’s search runs, the run’s span carries `.shimmer`, and its white parts drop their colour, since `text-foreground` would cover the band. The fake model searches three times when a message holds `FAKE_SEARCH_AGAIN`, which the end-to-end tests use. - **Stop** (slice 12): while the assistant works, the message box's Send button is Stop, one ` +

+
+
+ {parts.map((part) => ( + + ))} +
+
+
+ + ); +} diff --git a/src/features/chat/search-step.tsx b/src/features/chat/search-step.tsx index 1c6d624..8eb05f1 100644 --- a/src/features/chat/search-step.tsx +++ b/src/features/chat/search-step.tsx @@ -1,14 +1,16 @@ -"use client"; - -import { useId, useState } from "react"; -import { PassageQuote } from "@/features/transcripts/passage-quote"; +import type { ReactNode } from "react"; import { listNames, whoseWords, type SearchNotesPart } from "@/lib/chat"; import { cn } from "@/lib/utils"; /** The line shows this much of a query; the search used all of it. */ const SHOWN_QUERY_LENGTH = 80; -/** One search the assistant ran: one line that opens to its passages. */ +/** + * One search the assistant ran, as a plain line that doesn't open, the way + * opencode's desktop app shows a search tool: "Search" in medium weight, then + * the query and what came of it in grey (docs/decisions.md, Design, + * 2026-10-20). The passages the PRD quotes open from its source names. + */ export function SearchStep({ part, stopped = false, @@ -19,76 +21,64 @@ export function SearchStep({ stopped?: boolean; className?: string; }) { - const [open, setOpen] = useState(false); - const passagesId = useId(); - const query = shorten(part.input?.query ?? ""); + const result = outcome(part, stopped); + if (result === undefined) return null; + // The moving band colours the whole line, so "Search" turns white once + // the search is over. + const over = + stopped || + (part.state !== "input-streaming" && part.state !== "input-available"); + return ( +

+ + Search + {" "} + “{shorten(part.input?.query ?? "")}”{result} +

+ ); +} +/** What came of a search, after its query; nothing for a state it can't be in. */ +function outcome(part: SearchNotesPart, stopped: boolean): ReactNode { switch (part.state) { case "input-streaming": case "input-available": - return ( -

- {stopped - ? `Stopped searching the interviews for “${query}”` - : `Searching the interviews for “${query}”…`} -

- ); - + return stopped ? stopped : "…"; case "output-error": - return ( -

- Searching the interviews for “{query}” didn't work.{" "} - {part.errorText} -

- ); - + return didn't work. {part.errorText}; case "output-available": { const { passages } = part.output; - if (passages.length === 0) { - return ( -

- Searched the interviews for “{query}”: no passages came back. -

- ); - } + if (passages.length === 0) return no passages; return ( -
- - -
+ + passages from + {listNames(whoseWords(passages))} + ); } - default: - return null; + return undefined; } } +/** What follows the query, after a dot that screen readers skip. */ +function After({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + ); +} + function shorten(text: string): string { return text.length > SHOWN_QUERY_LENGTH ? `${text.slice(0, SHOWN_QUERY_LENGTH).trimEnd()}…` diff --git a/src/features/prd/prd-document.tsx b/src/features/prd/prd-document.tsx index de0383b..3d2365c 100644 --- a/src/features/prd/prd-document.tsx +++ b/src/features/prd/prd-document.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { RollingNumber } from "@/components/rolling-number"; import { Button } from "@/components/ui/button"; import type { SavedSection } from "@/lib/chat"; import { @@ -224,38 +225,6 @@ function useChanges( return changes; } -/** - * A number that rolls to its new value: the old one leaves as the new one - * comes in, upwards when it grows. Plain text at rest; with reduced motion - * the CSS changes it at once (`.roll` in `src/app/globals.css`). - */ -function RollingNumber({ value }: { value: number }) { - const [shown, setShown] = useState(value); - const [from, setFrom] = useState(); - if (value !== shown) { - setShown(value); - setFrom(shown); - } - if (from === undefined) return value; - return ( - from ? "up" : "down"} - > - - {from} - - setFrom(undefined)} - > - {value} - - - ); -} - function quoteCount({ section }: SavedSection): string { const { quotes, confirmed } = tally(section); return quotes === 0 diff --git a/src/server/ai/fake-chat-model.ts b/src/server/ai/fake-chat-model.ts index f9b6d73..9222a77 100644 --- a/src/server/ai/fake-chat-model.ts +++ b/src/server/ai/fake-chat-model.ts @@ -31,6 +31,9 @@ import type { // - "fake: flawed section": the saved section also quotes a researcher's // question, quotes one passage while citing another, and has a statement // that cites nothing, so the check has something to catch. +// - "fake: search again": it searches again with the same words for as long +// as the search is on offer (three times in all: `MAX_SEARCHES` in +// chat.ts), then goes on as usual, so the chat has searches in a row. export const FAKE_CHAT_MODEL = "fake-scripted-chat"; @@ -41,6 +44,8 @@ export const FAKE_FAILURES = { export const FAKE_FLAWED_SECTION = "fake: flawed section"; +export const FAKE_SEARCH_AGAIN = "fake: search again"; + /** A model searches with a short question, not a whole message. */ const MAX_QUERY_LENGTH = 300; @@ -109,6 +114,14 @@ export function createFakeChatModel({ return { stream: streamOf(searchStep(call, message), pacing) }; } + if ( + result?.toolName === "searchNotes" && + offers(call, "searchNotes") && + message.toLowerCase().includes(FAKE_SEARCH_AGAIN) + ) { + return { stream: streamOf(searchStep(call, message), pacing) }; + } + if ( result?.ok && result.toolName === "searchNotes" && @@ -208,7 +221,11 @@ function lastResult(call: ChatModelCall): ToolResult | undefined { /** The visitor's words, without the phrases that steer the fake. */ function searchQuery(message: string): string { let query = message; - for (const phrase of [...Object.values(FAKE_FAILURES), FAKE_FLAWED_SECTION]) { + for (const phrase of [ + ...Object.values(FAKE_FAILURES), + FAKE_FLAWED_SECTION, + FAKE_SEARCH_AGAIN, + ]) { query = query.replace(new RegExp(phrase, "gi"), " "); } return query.replace(/\s+/g, " ").trim().slice(0, MAX_QUERY_LENGTH); diff --git a/tests/AGENTS.md b/tests/AGENTS.md index f93f3ca..42ba3b4 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -14,7 +14,7 @@ Every feature gets a test in each layer it touches. A bug fix starts with a test - **Fixtures are deterministic.** Unit tests build their own small data inline. Integration and end-to-end tests use the sample notes in `notes/`, which `db:start`, `db:reset` and `notes:load` put in the database. Every test leaves the database holding exactly those notes. - **Fakes sit only at external boundaries.** That means the AI provider: the fake embedder (`src/server/ai/fake-embedder.ts`) and the fake chat model (`src/server/ai/fake-chat-model.ts`). The database is real in integration tests, never mocked. - **The fake embedder counts shared words,** without stemming or synonyms ("notification" and "notifications" are different words to it). A search test's question uses the words of the passages it expects; search by meaning can only be checked on staging. -- **The fake chat model follows a script:** it searches with the visitor's whole message, then quotes the closest passage. A message with "draft" or "section" in it makes it save a section instead, the one the message names last (so the check-in, "Introduction / Overview looks right. Please draft Goals / Objectives.", drafts Goals / Objectives), or the first when it names none: one statement per closest passage (up to three) quoting the first sentence of its answer, and one quoting the message's first sentence, then a reply saying what the check found. `FAKE_FLAWED_SECTION` in the message adds three flaws for the check to catch: a quote from the researcher's question, another passage's words under the first passage's id, and a statement that cites nothing. A message containing `FAKE_FAILURES.beforeAnswering` or `FAKE_FAILURES.partway` makes it fail once, so a retry succeeds. Its memory of failed messages lasts as long as the server, so an end-to-end test adds `testInfo.testId` to its message. Unit tests pass their own `failedMessages` and `chunkDelayInMs: 0`. +- **The fake chat model follows a script:** it searches with the visitor's whole message, then quotes the closest passage. A message with "draft" or "section" in it makes it save a section instead, the one the message names last (so the check-in, "Introduction / Overview looks right. Please draft Goals / Objectives.", drafts Goals / Objectives), or the first when it names none: one statement per closest passage (up to three) quoting the first sentence of its answer, and one quoting the message's first sentence, then a reply saying what the check found. `FAKE_SEARCH_AGAIN` makes it search again with the same words while the search is on offer (three times), so the chat folds its searches. `FAKE_FLAWED_SECTION` in the message adds three flaws for the check to catch: a quote from the researcher's question, another passage's words under the first passage's id, and a statement that cites nothing. A message containing `FAKE_FAILURES.beforeAnswering` or `FAKE_FAILURES.partway` makes it fail once, so a retry succeeds. Its memory of failed messages lasts as long as the server, so an end-to-end test adds `testInfo.testId` to its message. Unit tests pass their own `failedMessages` and `chunkDelayInMs: 0`. - **Next.js renders its own empty `role="alert"`** (the route announcer) on every page. Scope alert lookups to the part of the page under test. - **`server-only`:** Vitest maps it to an empty module (`vitest.config.ts`), so tests can import server code. diff --git a/tests/e2e/chat.spec.ts b/tests/e2e/chat.spec.ts index c9634ab..396a8d3 100644 --- a/tests/e2e/chat.spec.ts +++ b/tests/e2e/chat.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; -import { FAKE_FAILURES } from "@/server/ai/fake-chat-model"; +import { FAKE_FAILURES, FAKE_SEARCH_AGAIN } from "@/server/ai/fake-chat-model"; import { expectNoAccessibilityViolations } from "./accessibility"; // The chat on the home page, with the scripted fake model. It searches with @@ -35,33 +35,83 @@ function chatAlert(page: Page) { } test.describe("chat", () => { - test("answers with the search it ran as one line, which opens to the passages", async ({ + test("answers with the search it ran as one plain line naming whose passages came back", async ({ page, }) => { await openChat(page); await ask(page, QUESTION); await expect(conversation(page)).toContainText(QUESTION); - const line = page.getByRole("button", { - name: `Searched the interviews for “${QUESTION}”: closest passages from Sofia, Maya, Henk and Daan`, - }); - await expect(line).toHaveAttribute("aria-expanded", "false"); + await expect( + conversation(page).getByText( + `Search “${QUESTION}” · passages from Sofia, Maya, Henk and Daan`, + ), + ).toBeVisible(); await expect(conversation(page)).toContainText( "The closest passages come from Sofia, Maya, Henk and Daan.", ); await expect(conversation(page)).toContainText(FAKE_NOTE); + await expect(conversation(page).getByRole("button")).toHaveCount(0); + }); + + test("folds searches in a row into one line that slides open to them", async ({ + page, + }) => { + await openChat(page); + await ask(page, `${QUESTION} ${FAKE_SEARCH_AGAIN}`); + await expect(conversation(page)).toContainText(FAKE_NOTE); + + const line = conversation(page).getByRole("button", { + name: "Used 3 searches", + }); + const lines = conversation(page).getByText( + `Search “${QUESTION}” · passages from Sofia, Maya, Henk and Daan`, + ); + await expect(line).toHaveAttribute("aria-expanded", "false"); + await expect(lines).toHaveCount(3); + for (const each of await lines.all()) await expect(each).toBeHidden(); + + await line.click(); + + await expect(line).toHaveAttribute("aria-expanded", "true"); + for (const each of await lines.all()) await expect(each).toBeVisible(); + const [first, second] = await lines.all(); + const gap = + (await second!.boundingBox())!.y - (await first!.boundingBox())!.y; + // 28px rows, 8px apart, as opencode's (docs/decisions.md, Design, 2026-10-20). + expect(gap).toBe(36); await line.click(); - const passages = page.getByRole("list", { - name: `Passages found for “${QUESTION}”`, + await expect(line).toHaveAttribute("aria-expanded", "false"); + for (const each of await lines.all()) await expect(each).toBeHidden(); + }); + + test("opens the folded line at once under reduced motion", async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await openChat(page); + await ask(page, `${QUESTION} ${FAKE_SEARCH_AGAIN}`); + await expect(conversation(page)).toContainText(FAKE_NOTE); + const line = conversation(page).getByRole("button", { + name: "Used 3 searches", }); - await expect(passages.getByRole("listitem")).toHaveCount(5); + + await line.click(); + + // Neither the mark turns nor the lines slide: no transition starts. + expect( + await line.evaluate( + (button) => + button.parentElement!.getAnimations({ subtree: true }).length, + ), + ).toBe(0); await expect( - passages.getByRole("listitem").filter({ hasText: "ping me" }), - ).toHaveText( - /^Researcher: How would you want to know when Ineke adds something\?\s*I'd like my phone to ping me every time .*Henk$/, - ); + conversation(page) + .getByText(/^Search “/) + .first(), + ).toBeVisible(); }); test("streams the answer from the server as it's written", async ({ @@ -207,17 +257,21 @@ test.describe("chat", () => { await expect(chatAlert(page)).toHaveCount(0); }); - test("passes the accessibility check with an answer and its passages open", async ({ + test("passes the accessibility check with an answer's searches folded and open", async ({ page, }) => { await openChat(page); - await ask(page, QUESTION); + await ask(page, `${QUESTION} ${FAKE_SEARCH_AGAIN}`); await expect(conversation(page)).toContainText(FAKE_NOTE); - await page - .getByRole("button", { name: /^Searched the interviews for/ }) + await expectNoAccessibilityViolations(page); + + await conversation(page) + .getByRole("button", { name: "Used 3 searches" }) .click(); await expect( - page.getByRole("list", { name: /^Passages found for/ }), + conversation(page) + .getByText(/^Search “/) + .first(), ).toBeVisible(); await expectNoAccessibilityViolations(page); diff --git a/tests/e2e/layout.spec.ts b/tests/e2e/layout.spec.ts index 6c2b6a8..5f9c67d 100644 --- a/tests/e2e/layout.spec.ts +++ b/tests/e2e/layout.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; -import { FAKE_FAILURES } from "@/server/ai/fake-chat-model"; +import { FAKE_FAILURES, FAKE_SEARCH_AGAIN } from "@/server/ai/fake-chat-model"; import { expectNoAccessibilityViolations } from "./accessibility"; // The workspace's spacing and layout (docs/decisions.md, Design, 2026-10-20): @@ -304,18 +304,18 @@ test.describe("the workspace on a 1440px-wide laptop", () => { await expect(jump).toHaveCount(0); }); - test("offers Jump to latest once opened passages push the chat's end out of view", async ({ + test("offers Jump to latest once opened searches push the chat's end out of view", async ({ page, }) => { await page.setViewportSize({ width: 1440, height: 480 }); await openChat(page); - await ask(page, "Draft the first section."); + await ask(page, `Draft the first section. ${FAKE_SEARCH_AGAIN}`); await expect(turns(page).nth(1)).toContainText(ANSWER_END); const jump = page.getByRole("button", { name: "Jump to latest" }); await turns(page) .nth(1) - .getByRole("button", { name: /closest passages from/ }) + .getByRole("button", { name: "Used 3 searches" }) .click(); await expect(jump).toBeVisible(); diff --git a/tests/unit/conversation.test.tsx b/tests/unit/conversation.test.tsx index f0f9091..af05bbd 100644 --- a/tests/unit/conversation.test.tsx +++ b/tests/unit/conversation.test.tsx @@ -5,6 +5,7 @@ import { Conversation } from "@/features/chat/conversation"; import type { ChatMessage, SaveSectionPart, SearchNotesPart } from "@/lib/chat"; import type { CheckedSection } from "@/lib/prd"; import type { PassageMatch } from "@/lib/transcripts"; +import { stepText } from "./step-line"; const HENK: PassageMatch = { id: 1, @@ -50,6 +51,11 @@ function search( } as SearchNotesPart; } +/** A step's line, by its whole text. */ +function stepLine(text: string | RegExp): HTMLElement { + return screen.getByText(stepText(text)); +} + function show(messages: ChatMessage[], status: ChatStatus = "ready") { return render(); } @@ -74,13 +80,11 @@ describe("Conversation", () => { show([question, answer(search("input-available"))], "streaming"); expect( - screen.getByText( - "Searching the interviews for “notifications for new items”…", - ), + stepLine("Search “notifications for new items”…"), ).toBeInTheDocument(); }); - it("shows a finished search as one line naming whose passages came back, which opens to them", () => { + it("shows a finished search as one plain line naming whose passages came back", () => { show([ question, answer( @@ -89,27 +93,13 @@ describe("Conversation", () => { ), ]); - const line = screen.getByRole("button", { - name: "Searched the interviews for “notifications for new items”: closest passages from Henk and Daan", - }); - const controlled = document.getElementById( - line.getAttribute("aria-controls") ?? "", - ); - expect(line).toHaveAttribute("aria-expanded", "false"); - expect(controlled).not.toBeVisible(); - - fireEvent.click(line); - - expect(line).toHaveAttribute("aria-expanded", "true"); - const passages = screen.getByRole("list", { - name: "Passages found for “notifications for new items”", - }); - expect(passages).toBe(controlled); - const [first, second] = within(passages).getAllByRole("listitem"); - expect(first).toHaveTextContent( - /^Researcher:\s*How would you want to know when Ineke adds something\?\s*I'd like my phone to ping me.*\s*Henk$/, - ); - expect(second).toHaveTextContent(/Not as they come in\.\s*Daan$/); + expect( + stepLine( + "Search “notifications for new items” · passages from Henk and Daan", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByText(/Researcher:/)).not.toBeInTheDocument(); }); it("names each person once, closest first", () => { @@ -122,10 +112,17 @@ describe("Conversation", () => { ), ]); + expect(stepLine(/passages from Daan and Henk$/)).toBeInTheDocument(); + }); + + it("says when a search found nothing", () => { + show([ + question, + answer(search("output-available", { output: { passages: [] } })), + ]); + expect( - screen.getByRole("button", { - name: /closest passages from Daan and Henk$/, - }), + stepLine("Search “notifications for new items” · no passages"), ).toBeInTheDocument(); }); @@ -140,12 +137,107 @@ describe("Conversation", () => { ]); expect( - screen.getByText( - "Searching the interviews for “notifications for new items” didn't work. The search didn't work this time.", + stepLine( + "Search “notifications for new items” · didn't work. The search didn't work this time.", ), ).toBeInTheDocument(); }); + describe("searches in a row", () => { + const done = (toolCallId: string, query: string, passages = [HENK]) => + search("output-available", { + toolCallId, + input: { query }, + output: { passages }, + }); + + it("fold into one line that opens to their plain lines", () => { + show([ + question, + answer(done("call-1", "pings"), done("call-2", "lists", [DAAN]), { + type: "text", + text: "Henk does.", + }), + ]); + + const line = screen.getByRole("button", { name: "Used 2 searches" }); + const lines = document.getElementById( + line.getAttribute("aria-controls") ?? "", + ); + expect(line).toHaveAttribute("aria-expanded", "false"); + expect(lines).toHaveAttribute("inert"); + + fireEvent.click(line); + + expect(line).toHaveAttribute("aria-expanded", "true"); + expect(lines).not.toHaveAttribute("inert"); + expect(lines).toHaveTextContent( + /^Search “pings” · passages from Henk\s*Search “lists” · passages from Daan$/, + ); + + fireEvent.click(line); + + expect(line).toHaveAttribute("aria-expanded", "false"); + expect(lines).toHaveAttribute("inert"); + }); + + it("start a new line after text, and never fold a save", () => { + show([ + question, + answer( + done("call-1", "pings"), + done("call-2", "lists"), + { type: "text", text: "Two so far." }, + done("call-3", "apps"), + { + type: "tool-saveSection", + toolCallId: "save-1", + state: "input-available", + input: { section: "Goals / Objectives", statements: [] }, + } as SaveSectionPart, + done("call-4", "phones"), + ), + ]); + + expect(screen.getAllByRole("button")).toHaveLength(1); + expect( + screen.getByRole("button", { name: "Used 2 searches" }), + ).toBeInTheDocument(); + expect( + stepLine("Search “apps” · passages from Henk"), + ).toBeInTheDocument(); + expect( + stepLine("Search “phones” · passages from Henk"), + ).toBeInTheDocument(); + }); + + it("count up as each search starts, keeping the line open", () => { + const first = [done("call-1", "pings"), done("call-2", "lists")]; + const { rerender } = show([question, answer(...first)], "streaming"); + fireEvent.click(screen.getByRole("button", { name: "Used 2 searches" })); + + rerender( + , + ); + + const line = screen.getByRole("button", { name: "Used 3 searches" }); + expect(line).toHaveAttribute("aria-expanded", "true"); + expect(stepLine("Search “apps”…")).toBeInTheDocument(); + }); + }); + describe("while an answer is on its way, exactly one line moves", () => { /** The lines with the moving band (`.shimmer` in `globals.css`). */ function moving(container: HTMLElement) { @@ -160,6 +252,22 @@ describe("Conversation", () => { }); it("moves the step in progress, with no Working…", () => { + const { container } = show( + [ + question, + answer({ type: "text", text: "Looking." }, search("input-available")), + ], + "streaming", + ); + + expect(screen.queryByText("Working…")).not.toBeInTheDocument(); + expect(moving(container)).toHaveLength(1); + expect(moving(container)[0]).toHaveTextContent( + "Search “notifications for new items”…", + ); + }); + + it("moves the folded line while a search in it runs", () => { const { container } = show( [ question, @@ -173,9 +281,7 @@ describe("Conversation", () => { expect(screen.queryByText("Working…")).not.toBeInTheDocument(); expect(moving(container)).toHaveLength(1); - expect(moving(container)[0]).toHaveTextContent( - "Searching the interviews for “notifications for new items”…", - ); + expect(moving(container)[0]).toHaveTextContent(/^Used 2 searches$/); }); it("moves only the latest of two steps in progress", () => { @@ -184,6 +290,7 @@ describe("Conversation", () => { question, answer( search("input-available"), + { type: "text", text: "And also" }, search("input-streaming", { toolCallId: "call-2", input: { query: "pings" }, @@ -194,9 +301,7 @@ describe("Conversation", () => { ); expect(moving(container)).toHaveLength(1); - expect(moving(container)[0]).toHaveTextContent( - "Searching the interviews for “pings”…", - ); + expect(moving(container)[0]).toHaveTextContent("Search “pings”…"); }); it("shows Working… at the end of the answer between steps, and the finished step stays still", () => { @@ -438,7 +543,7 @@ describe("Conversation", () => { screen.getByRole("list", { name: "Conversation" }), ).getAllByRole("listitem"); expect(assistant).toHaveTextContent( - /Searched the interviews for “notifications for new items”: closest passages from Henk.*Henk wants\s*Stopped searching the interviews for “notifications for new items”\s*You stopped this answer\.$/, + /Search “notifications for new items” · passages from Henk.*Henk wants\s*Search “notifications for new items” · stopped\s*You stopped this answer\.$/, ); expect(screen.queryByText(/Searching/)).not.toBeInTheDocument(); expect(screen.queryByText(/didn't work/)).not.toBeInTheDocument(); diff --git a/tests/unit/fake-chat-model.test.ts b/tests/unit/fake-chat-model.test.ts index 17f143e..2d0a596 100644 --- a/tests/unit/fake-chat-model.test.ts +++ b/tests/unit/fake-chat-model.test.ts @@ -9,6 +9,7 @@ import { FAKE_CHAT_MODEL, FAKE_FAILURES, FAKE_FLAWED_SECTION, + FAKE_SEARCH_AGAIN, } from "@/server/ai/fake-chat-model"; type Prompt = ChatModelCall["prompt"]; @@ -161,6 +162,32 @@ describe("the fake chat model", () => { expect(textOf(parts)).toMatch(/^I can only answer from the interviews/); }); + describe("asked to search again", () => { + const again = `Who wants a ping? ${FAKE_SEARCH_AGAIN}`; + + it("searches again with the same words while the search is on offer", async () => { + const parts = await partsOf(afterSearch(again, FOUND)); + + expect(parts).toContainEqual( + expect.objectContaining({ + type: "tool-call", + toolName: "searchNotes", + input: JSON.stringify({ query: "Who wants a ping?" }), + }), + ); + expect(textOf(parts)).toBe(""); + }); + + it("answers once the search is off", async () => { + const parts = await partsOf(afterSearch(again, FOUND), fake(), []); + + expect(parts.some((part) => part.type === "tool-call")).toBe(false); + expect(textOf(parts)).toMatch( + /^I searched the interviews for “Who wants a ping\?”\./, + ); + }); + }); + it("reports the tokens it estimates for each call", async () => { const parts = await partsOf([visitorSays("Who wants a ping?")]); diff --git a/tests/unit/kept-conversation.test.tsx b/tests/unit/kept-conversation.test.tsx index 35aa079..3e2e69c 100644 --- a/tests/unit/kept-conversation.test.tsx +++ b/tests/unit/kept-conversation.test.tsx @@ -20,6 +20,7 @@ import { import { Workspace } from "@/features/workspace/workspace"; import { textOf, type ChatMessage } from "@/lib/chat"; import type { CheckedSection } from "@/lib/prd"; +import { stepText } from "./step-line"; // The conversation kept in the browser, so the PRD survives a reload: what's // kept, what comes back, and what happens to a kept conversation that no @@ -416,7 +417,7 @@ describe("the home page after a reload", () => { expect(screen.getByText("You stopped this answer.")).toBeInTheDocument(); expect( - screen.getByText("Stopped searching the interviews for “pings”"), + screen.getByText(stepText("Search “pings” · stopped")), ).toBeInTheDocument(); expect(introStatements()).toHaveTextContent( "Henk wants a ping for every item.", diff --git a/tests/unit/step-line.ts b/tests/unit/step-line.ts new file mode 100644 index 0000000..d7e27ed --- /dev/null +++ b/tests/unit/step-line.ts @@ -0,0 +1,14 @@ +import type { MatcherFunction } from "@testing-library/react"; + +/** + * Finds a step's line in the chat by its whole text, for `getByText` and + * `findByText`: its words sit in several elements ("Search" apart from its + * query), which the default matcher doesn't join. + */ +export function stepText(text: string | RegExp): MatcherFunction { + return (_, element) => { + if (element?.tagName !== "P") return false; + const content = (element.textContent ?? "").replace(/\s+/g, " ").trim(); + return typeof text === "string" ? content === text : text.test(content); + }; +} diff --git a/tests/unit/workspace.test.tsx b/tests/unit/workspace.test.tsx index cacf234..41e078b 100644 --- a/tests/unit/workspace.test.tsx +++ b/tests/unit/workspace.test.tsx @@ -22,6 +22,7 @@ import { FIRST_SECTION_REQUEST, GROCERY_DELIVERY_SUGGESTION, } from "@/lib/suggestions"; +import { stepText } from "./step-line"; // The home page's workspace, with the server's answers stubbed: the chat's // transport posts to /api/chat with the browser's fetch, so each test decides @@ -537,11 +538,7 @@ describe("the Stop button", () => { target: { value: "Draft the first section." }, }); fireEvent.click(screen.getByRole("button", { name: "Send" })); - await screen.findByText( - "Searching the interviews for “pings”…", - {}, - { timeout: 3000 }, - ); + await screen.findByText(stepText("Search “pings”…"), {}, { timeout: 3000 }); } function messageBox() { @@ -563,7 +560,7 @@ describe("the Stop button", () => { await screen.findByText("You stopped this answer."), ).toBeInTheDocument(); expect( - screen.getByText("Stopped searching the interviews for “pings”"), + screen.getByText(stepText("Search “pings” · stopped")), ).toBeInTheDocument(); expect(screen.getByText("I saved Introduction")).toBeInTheDocument(); expect(