Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions KNOWN-ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,10 @@ The model's behaviour isn't tracked here: it differs on every run, so one run ca
What happens and how to reproduce it. What's known about the cause, with guesses labelled as guesses. What fixing it involves.
-->

## KI-7 · An end-to-end test failed once under load, then passed
**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.
Again during slice 20 (2026-10-21), in the first full run on the finished code: 6 tests (`prd.spec.ts`'s Drafting, light-up, See it and copy tests), each waiting past 5 seconds for the first draft's answer while it showed "Checking the quotes in Introduction / Overview…". All of `prd.spec.ts` passed on its own right after, and the next full run passed all 67.

## KI-8 · `next dev` adds its own block to `AGENTS.md`
**Found:** 2026-10-20 · **Severity:** low
Every `next dev` (`npm run dev`, `app`, `app-build`) appends a block between `<!-- BEGIN:nextjs-agent-rules -->` and `<!-- END:nextjs-agent-rules -->` to the root `AGENTS.md`, telling agents to read the docs in `node_modules/next/dist/docs/`, and writes it again when it is removed, so every checkout that ran the dev server shows an uncommitted change. Next.js 16.4.2 does it from `node_modules/next/dist/server/lib/start-server.js` and `app-info-log.js`, unless `next.config` sets `agentRules: false`, which its log names as the switch. Fixing it means the owner choosing: set `agentRules: false` (the project already says to look docs up, `AGENTS.md`, Boundaries), or commit the block. Until then, leave it out of commits (`git checkout -- AGENTS.md`).

## KI-9 · The local database's gateway refused a search once under load
**Found:** 2026-10-21 · **Severity:** low
In one full end-to-end run of three passes (201 tests, six workers) on this Windows machine, a search got "An invalid response was received from the upstream server" from the local Supabase stack's gateway, so the fake model's answer said the search didn't work, saved nothing, and `tests/e2e/prd.spec.ts`'s "follows the newest message…" waited in vain for the check-in. The app handled it as it should. The next run of 201 passed, and it hasn't been seen in CI. A guess: the gateway in Docker on Windows, under six browsers at once. If it happens again, note the run here; fixing it would start with the gateway's logs (`docker logs` of the stack's Kong container) at that moment.
13 changes: 10 additions & 3 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@

### 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 20 is done on `feat/thinking-line` (`.claude/worktrees/slice-20-thinking`), from `main` at b446e43 (slice 19's merge), waiting for the owner's push and merge. Local checks on 2026-10-21: `verify` (488 unit tests, 2 files new or changed for the thinking lines) passed, its build on a second try after a network error reaching Google Fonts; `test:e2e` (67, 1 new in `tests/e2e/chat.spec.ts`; slice 11's and slice 15's tests now read "Thinking" and count the thought lines) passed in the second full run, the first failing 6 waiting on the fake model's answer (KI-7). The page checked on a dev server from the worktree with the fakes, at 1440px and 375px: "Thinking" at 13px, medium, on a 16px line with the moving band; "Thought 2s" with the word in white and the time in grey; inside "Used 3 searches" the thoughts and searches alternate, rows 28px high and 36px apart; the times kept through a reload; no sideways scroll on a phone. It changes only `src/features` and its tests, nothing in `src/server` or `src/lib`; the times go back to the server inside the answers, and a unit test shows the model's input is the same with and without them, so the measurement lane needn't wait for it.
- **Doing:** slice 21 is done on `fix/steady-e2e-waits` (`.claude/worktrees/fix-e2e-waits`), from `main` at 9ec158c with slice 20's plan update on top, waiting for the owner's push and merge. Slice 20's merge didn't reach staging: the deploy's `test:e2e` failed (run 39574173219; its log needs a GitHub sign-in, so which tests is unknown), while its pull request's run passed on the same code. Local checks on 2026-10-21: `verify` (489 unit tests, 1 new) passed; `test:e2e` three times over (201) passed, after one such run failed on KI-9; the Jump to latest test 120 times of 120, against 2 failures in 60 before. The browser pane couldn't show the jump's race (in its small frame the slide changed no height), so the end-to-end test is the check. It changes `src/features/chat/chat.tsx`, `playwright.config.ts` and tests, nothing in `src/server` or `src/lib`.
- **Next:**
1. Slice 8, the rate limit, once 9d is done and the owner has decided its legal basis and size (*Deferred*). No other piece is planned for this lane.
- **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; the words' fade; the copy button, its label and the clipboard; Inter and its resized fallback, which needs Arial installed, so Linux and Android show the system's font until Inter arrives; a table's cells keeping their 144px and its box's sideways scroll; the Thinking line's band). On staging, a real reply's table, and the Thinking line through the real model's waits with the times it shows (the fake model's are about 0s). On staging, a reply that arrives whole fading in as one, and a section's copy over HTTPS pasted into another app. 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/thinking-line`, 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.
- **Waiting on the owner:** pushing `fix/steady-e2e-waits`, its pull request and the merge, after which the deploy brings slices 20 and 21 to staging; 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.
Expand Down Expand Up @@ -114,4 +114,11 @@

### 19 · Tables like opencode's · risk: normal · status: done (71e6e93)

### 20 · Show when the model is thinking · risk: normal · status: done (on `feat/thinking-line`; its id on `main` goes here after the merge)
### 20 · Show when the model is thinking · risk: normal · status: done (9ec158c)

### 21 · Steady end-to-end tests · risk: normal · status: done (on `fix/steady-e2e-waits`; its id on `main` goes here after the merge)
- **Goal:** the end-to-end tests stop failing at random under load, which stopped the staging deploy of slice 20's merge on 2026-10-21.
- **Acceptance:**
- A wait for the fake model's answer may take 15 seconds, not 5; no check's condition changes.
- Jump to latest, clicked while the conversation still grows, ends at the new end, with a unit test that fails without the fix.
- The full suite passes three times over with six workers, and the Jump to latest test 120 times in 120.
6 changes: 6 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
// The fake model streams its parts 15ms apart, so a drafted section takes
// about 1.5s alone and, under load, past the default 5s: full runs with six
// workers timed out waiting for an answer to end now and then (2026-10-20
// and 21), and 10 tests of 90 with twelve. The waits keep their conditions;
// only how long they may take changes.
expect: { timeout: 15_000 },
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL,
Expand Down
2 changes: 1 addition & 1 deletion src/features/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
- **A stopped answer** keeps its parts as they were: its text so far, its finished searches, any section it saved (the PRD keeps it). `stopped` holds the ids of the questions whose answers were stopped, so an answer stopped before its first part still says "You stopped this answer." under its question. A step still running reads as stopped ("Stopped searching the interviews for “…”", "Stopped checking the quotes in …", without a full stop, like the finished step lines), never as running or failed. What goes back to the server is trimmed in the transport (`prepareSendMessagesRequest`, `finishedOnly` in `workspace/stopped-answer.ts`): unfinished steps are dropped, and so is an answer that finished nothing; the server's format check would refuse a half-arrived tool call, and the model would see a call without its result.
- **The message box grows** with its text through `field-sizing: content`, from three lines (`min-h-24`) to 180px (`max-h-45`), then scrolls; Chrome 123, Firefox 152 and Safari 26.2 support it (MDN's compatibility data, read 2026-10-20 through Context7), and older browsers keep the three lines. A `ResizeObserver` keeps the chat on its newest message when the conversation's pane gets shorter, as the box grows or the suggestion shows, unless the visitor scrolled up; jsdom has none, so `tests/unit/setup.ts` stubs it.
- **Scrolling panes are `relative`:** Tailwind's `sr-only` is `position: absolute`. Inside a scrolling pane that isn't positioned, those labels are placed against the page, stretch it and make the whole page scroll (found 2026-10-18). The PRD pane is also focusable (`tabIndex={0}`), or axe reports `scrollable-region-focusable` before it holds a link.
- **Jump to latest** (slice 14): shows while the chat is 48px or more off its end (`FOLLOW_MARGIN`), after a scroll up or when the conversation grows without one (a `ResizeObserver` on the conversation's `<ol>`, which changes only the button, not whether the chat follows; in Chromium, opening a folded line while the pane gains its scrollbar instead resizes the pane, and the pane's own observer takes the chat to its end). It's the scrolling pane's last child, a zero-height `sticky bottom-0` box whose `-mt-6` cancels the `space-y-6` margin it gives the item before it (Tailwind 4 puts that margin on every child but the last, read 2026-10-20 through Context7), with the button absolute inside; so the pane stays `#chat-title + div` with the same children, which `tests/e2e/layout.spec.ts` measures. `hidden lg:block`, since a phone's chat doesn't scroll on its own. A click sets following, moves focus to the message box (or the chat's heading once the allocation is used up and the box is gone) before the button unmounts, and calls `scrollTo` with `behavior: "smooth"`, or `"instant"` under `prefers-reduced-motion: reduce` (MDN, `Element.scrollTo`, read 2026-10-20 through Context7). While that smooth scroll runs, its downward steps don't count as the visitor leaving the end (`jumping`); a step upward does. It has an opaque background, as it sits over the text. jsdom lays nothing out and has no `matchMedia` or `scrollTo`, so `tests/unit/workspace.test.tsx` gives the pane a size, stubs both and reports the conversation's resizes itself.
- **Jump to latest** (slice 14): shows while the chat is 48px or more off its end (`FOLLOW_MARGIN`), after a scroll up or when the conversation grows without one (a `ResizeObserver` on the conversation's `<ol>`, which changes only the button, not whether the chat follows; in Chromium, opening a folded line while the pane gains its scrollbar instead resizes the pane, and the pane's own observer takes the chat to its end). It's the scrolling pane's last child, a zero-height `sticky bottom-0` box whose `-mt-6` cancels the `space-y-6` margin it gives the item before it (Tailwind 4 puts that margin on every child but the last, read 2026-10-20 through Context7), with the button absolute inside; so the pane stays `#chat-title + div` with the same children, which `tests/e2e/layout.spec.ts` measures. `hidden lg:block`, since a phone's chat doesn't scroll on its own. A click sets following, moves focus to the message box (or the chat's heading once the allocation is used up and the box is gone) before the button unmounts, and calls `scrollTo` with `behavior: "smooth"`, or `"instant"` under `prefers-reduced-motion: reduce` (MDN, `Element.scrollTo`, read 2026-10-20 through Context7). While that smooth scroll runs, its downward steps don't count as the visitor leaving the end (`jumping`); a step upward does. If the conversation grows meanwhile, as opened searches still sliding open do, the conversation's observer scrolls on to the new end instead of showing the button; before that, the jump stopped at the old end and stayed there, with no button (found 2026-10-21, `tests/e2e/layout.spec.ts`, 2 runs in 60). It has an opaque background, as it sits over the text. jsdom lays nothing out and has no `matchMedia` or `scrollTo`, so `tests/unit/workspace.test.tsx` gives the pane a size, stubs both and reports the conversation's resizes itself.
- **Only "See it" scrolls the PRD.** The chat follows its newest message unless the visitor scrolled up; on a phone, where the panes stack and don't scroll on their own, it doesn't follow.

## After a reload (`workspace/stored-conversation.ts`)
Expand Down
28 changes: 18 additions & 10 deletions src/features/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ const FOLLOW_MARGIN = 48;
/** The message box, which takes focus when the visitor asks their own question. */
export const MESSAGE_BOX_ID = "chat-message";

/** Jump to latest's scroll: smooth, or at once under reduced motion. */
function scrollToEnd(element: HTMLElement) {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({
top: element.scrollHeight,
behavior: reduced ? "instant" : "smooth",
});
}

/**
* The chat with the assistant: the conversation, which scrolls on its own,
* and under it a suggested message, when there is one, and the message box.
Expand Down Expand Up @@ -95,15 +104,20 @@ export function Chat({

// The conversation can grow without a scroll, as when the visitor opens a
// search's passages: that leaves the chat away from its end too. Only the
// button changes; whether the chat follows stays as it was. The list
// exists once the conversation has a message.
// button changes; whether the chat follows stays as it was. A jump on its
// way goes on to the new end instead, as when the searches it was offered
// for are still sliding open. The list exists once the conversation has a
// message.
const empty = messages.length === 0;
useEffect(() => {
const element = scroller.current;
const list = element?.querySelector(":scope > ol");
if (!element || !list) return;
const observer = new ResizeObserver(() => {
if (jumping.current) return;
if (jumping.current) {
scrollToEnd(element);
return;
}
setAwayFromEnd(
element.scrollHeight - element.scrollTop - element.clientHeight >=
FOLLOW_MARGIN,
Expand Down Expand Up @@ -138,13 +152,7 @@ export function Chat({
// allocation is used up and the box is gone.
if (quotaUsedUp) heading.current?.focus();
else box.current?.focus();
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
element.scrollTo({
top: element.scrollHeight,
behavior: reduced ? "instant" : "smooth",
});
scrollToEnd(element);
}

function send() {
Expand Down
Loading
Loading