diff --git a/.github/workflows/AGENTS.md b/.github/workflows/AGENTS.md index b711322..f4cd00c 100644 --- a/.github/workflows/AGENTS.md +++ b/.github/workflows/AGENTS.md @@ -43,7 +43,7 @@ For each GitHub environment, `staging` and `production` (deployment branches: `m - `VERCEL_TOKEN`: scoped to that Vercel project. - **Variables:** `SUPABASE_URL`, `CLOUDFLARE_ACCOUNT_ID`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`. -Vercel project settings (Production environment only, credentials as type Secret): `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `AI_PROVIDER=cloudflare`, `CLOUDFLARE_API_TOKEN`. `README.md` has the owner's steps, click by click. +Vercel project settings (Production environment only, credentials as type Secret): `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `AI_PROVIDER=cloudflare`, `SUPABASE_SECRET_KEY` (the rate limit's counts, since slice 8), `CLOUDFLARE_API_TOKEN`. `README.md` has the owner's steps, click by click. ## Gotchas - **Migrations must not break the running code.** The old code keeps running between migrate and deploy, after a failed deploy, and after a rollback. Add before you remove: a new column first, dropping the old one in a later change. diff --git a/README.md b/README.md index e7af885..2cb73ca 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Next.js · TypeScript · Vercel AI SDK · gpt-oss-120b on Cloudflare Workers AI · Supabase Postgres with pgvector · Playwright · GitHub Actions -**Status:** built and running on a private staging site. The public demo opens at launch, after a per-visitor message limit. [More below](#status). +**Status:** built and running on a private staging site. The public demo opens at launch. [More below](#status). ## The story @@ -28,15 +28,15 @@ The check proves a quote is real, not that it's read fairly, which is why every | **Tool calling with limits** | The model decides when to search and when to save. The app caps it at three searches, then allows only saving, then takes the tools away so it replies. | [`chat.ts`](src/server/chat.ts) | | **AI output checked in code** | Every quote is checked against the cited person's answer, never the interviewer's question or the assistant's own words. | [`citations.ts`](src/server/citations.ts) | | **AI behaviour, measured** | Fixed requests, five runs each on the real model, scored on the product's rules. A change is compared with the baseline on a one-sided Fisher's exact test, and any other rule that gets worse is flagged. | [`measurement/`](src/server/measurement/) | -| **Failures in plain sight** | A broken or cut-off answer stays on screen marked unfinished. A used-up daily AI allowance and a busy provider each get their own message. | [`cloudflare.ts`](src/server/ai/cloudflare.ts), [`quota.tsx`](src/features/workspace/quota.tsx) | +| **Failures in plain sight** | A broken or cut-off answer stays on screen marked unfinished. A used-up daily AI allowance, a visitor's used-up 10 messages for the day and a busy provider each get their own message. The limit counts a keyed hash of the visitor's address and the day, never the address itself. | [`cloudflare.ts`](src/server/ai/cloudflare.ts), [`rate-limit.ts`](src/server/rate-limit.ts), [`quota.tsx`](src/features/workspace/quota.tsx) | | **Safe by default** | Runs locally with no accounts or keys. A guard stops every command that points at a hosted database or holds a stray key. Every table starts closed to the public API. | [`env-guard.ts`](src/lib/env-guard.ts), [`supabase/AGENTS.md`](supabase/AGENTS.md) | | **Tested in four layers** | Unit, integration, SQL and end-to-end tests. CI runs on every pull request, and every merge deploys to staging. | [`tests/`](tests/), [`.github/workflows/`](.github/workflows/) | ## Status -- **Built:** everything in the story, running on a private staging site (Vercel, Supabase in Frankfurt, Cloudflare Workers AI on its free plan). +- **Built:** everything in the story, running on a private staging site (Vercel, Supabase in Frankfurt, Cloudflare Workers AI on its free plan), with a limit of 10 messages per visitor a day, so one visitor can't use up the free daily AI allowance. - **Measured:** the AI's behaviour, on fixed requests against a baseline ([how](#measuring-the-ais-behaviour)). -- **Before launch:** a per-visitor message limit, so one visitor can't use up the free daily AI allowance; the public demo; and a short screen recording here. +- **Before launch:** the public demo, and a short screen recording here. The [product description](docs/product/prd.md), its [scope](docs/product/mvp.md), the [decisions](docs/decisions.md) and the [build plan](docs/plan.md) are all in the repository. @@ -181,7 +181,7 @@ The staging and production keys never sit on a development machine; a Workers AI | Name | Where | What | | --- | --- | --- | | `SUPABASE_DB_URL` | GitHub environment secret | The database's session pooler address, with its password | -| `SUPABASE_SECRET_KEY` | GitHub environment secret | `sb_secret_…`, for loading the interviews | +| `SUPABASE_SECRET_KEY` | GitHub environment secret, and Vercel (Secret) | `sb_secret_…`, for loading the interviews and counting each visitor's messages | | `CLOUDFLARE_API_TOKEN` | GitHub environment secret, and Vercel (Secret) | The Workers AI token for that environment | | `VERCEL_TOKEN` | GitHub environment secret | A Vercel token for that environment's project only | | `SUPABASE_URL` | GitHub environment variable, and Vercel (Config) | `https://.supabase.co` | @@ -231,6 +231,7 @@ First for `prd-assistant-staging`, then for `prd-assistant`. 2. Open **Settings → Git** and select **Disconnect**. From now on, only GitHub Actions deploys. 3. Under **Settings → Environment Variables**, add these to the **Production** environment only, leaving Preview and Development empty: - `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `CLOUDFLARE_ACCOUNT_ID` and `AI_PROVIDER` = `cloudflare`, as **Config**. + - `SUPABASE_SECRET_KEY`, as **Secret**. Without it the chat refuses every message, since it can't count them against each visitor's daily limit. - `CLOUDFLARE_API_TOKEN`, as **Secret**. The production project gets this one at launch. 4. For staging only, under **Settings → Deployment Protection**, turn on **Vercel Authentication** and choose **All Deployments**, not Standard Protection, which leaves the main address public. All Deployments is available on every plan (https://vercel.com/docs/security/deployment-protection). Then only you can open staging, and strangers can't use up the free AI allowance. 5. Note the **Project ID** from **Settings → General**, and the **Team ID** from your team's **Settings → General**. diff --git a/docs/decisions.md b/docs/decisions.md index ee46660..480ed24 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -45,7 +45,7 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque ## Access - 2026-10-17 · Visitors are anonymous: no sign-in, no roles, no admin screen; the owner works only through staging, the quality check and the hosting settings; English only, no currency · No sign-in or translation setup in stage 3 - 2026-10-17 · When the free quota runs out, the chat input is hidden and the message links to the README's screen recording and the repository · Replaces the plain friendly message -- 2026-10-17 · Rate limit: 30 messages per visitor per day, recognised by hashed IP address kept only for the limit window; the provider's daily free allocation is the tighter cap · Needs a legal basis the owner chooses (see Money and legal) +- 2026-10-26 · Rate limit: 10 messages per visitor per UTC day, recognised by a keyed hash of the IP address (an IPv6 address by its /64) and the day, never the address itself; a row is deleted by the first message after its day; it counts only messages to the real model (the owner, slice 8) · At about 350 neurons an answer, 30 a day would let one visitor use the whole day's allocation; 10 caps one visitor at about a third. Replaces 30 a day (2026-10-17). How it's built: `src/server/AGENTS.md`, *The rate limit* ## External services - 2026-10-17 · Every AI key comes from an account or project with no billing attached, so running out of quota fails instead of charging · No spending limit to manage; quota exhaustion is the expected failure. The measurement stays on the Free plan too (External services, 2026-10-19) @@ -69,6 +69,7 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque ## Money and legal - 2026-10-17 · Official sources for the IP-based rate limit (not legal advice; the owner decides the legal basis): GDPR (https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32016R0679) recital 30 (IP addresses are online identifiers), recital 26 (pseudonymised, e.g. hashed, data is still personal data), recital 49 (processing strictly necessary for network and information security can be a legitimate interest), Art. 5(1)(c) and (e) (data minimisation, storage limitation), Art. 6(1)(f) (legitimate interests), Art. 13 (information to give visitors); CJEU C-582/14 Breyer (https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:62014CJ0582): a dynamic IP address can be personal data for a website operator · Decide the legal basis and whether a short privacy notice is needed before launch +- 2026-10-26 · The rate limit's legal basis: the owner treats it as strictly necessary to keep the free demo available to everyone, like a necessary cookie, so no consent is asked and no banner shown; it sets no cookie. A deletion request needs no process of its own: a count is a keyed hash that can't be traced to a visitor and is gone after its day (the owner: "lets not care too much about the legal basis"; not legal advice) · Settles the item deferred on 2026-10-17. Whether a short privacy notice mentions it stays with the legal pages in stage 5 - 2026-10-17 · Cloudflare's terms (Self-Serve Agreement and Developer Platform terms, read 2026-10-17; not legal advice): no clause limiting the free plan to private, test or commercial use, and no restriction for EEA visitors; each model's own licence and use policy apply · The public demo can run on the free plan. Replaces the reading of Mistral's terms - 2026-10-17 · A line under the chat box says it's a demo with fictional notes and not to enter personal information · Covers free-tier data use by the provider - 2026-10-17 · No cost display and no prices file; token usage is logged on the server per answer and not shown in the app, and the quality check prints the total tokens · Replaces brief feature 5 (cost per answer and per PRD in the app) @@ -81,8 +82,6 @@ Decisions that shape the app, one line each: `YYYY-MM-DD · decision · conseque ## Deferred -- 2026-10-17 · The legal basis for the hashed-IP rate limit, and handling requests to delete personal data (keeping it only for the limit window is decided) · decide before the rate-limit piece of work in stage 4, the first that stores personal data · affects the rate limit and a possible privacy notice - 2026-10-17 · Legal pages, including whether a short privacy notice is needed; the domain, search engines, alerts, cookies, tracking, tax and receipts are already settled as none · decide in stage 5 · affects the launch -- 2026-10-18 · The per-visitor rate limit's size (now 30 messages a day, under Access), from what an answer costs on the chat model as the staging log shows: later sections cost more, because every call resends the conversation, and at 30 messages one visitor could use the whole day's allocation, which staging and production share · decide before the rate-limit piece of work (slice 8), from the token counts the staging log shows by then · affects the rate limit - 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, and what happens after a miss · decide before launch · affects the launch diff --git a/docs/plan.md b/docs/plan.md index a1180ac..1473654 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -21,14 +21,15 @@ ### 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:** nothing. Slice 28, the owner's interface review so far, is merged (0ca1d52, 2026-10-26): "Start over" asks in a pop-over under its button; the neutral palette of the owner's site, with `--prose` for replies and statements; the PRD's headings lighter; the first screen centred, with the households linking to their interviews and a glimpse of the workspace, arriving line by line once. How it's built: `src/features/AGENTS.md`, *"Start over" asks first*, *The palette* and *The intro*. -- **Before it:** slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11). +- **Doing:** slice 8, the rate limit, built on `feat/rate-limit` (worktree `.claude/worktrees/rate-limit`): 10 messages a visitor a UTC day, counted by a keyed hash of the address and the day in `message_counts`, which only the secret key reaches; only messages to the real model count. Its independent review (2026-10-26) found no blocker, and none of its four low findings needed a code change; the branch waits for the push. Before its merge, the owner adds `SUPABASE_SECRET_KEY` to the Vercel staging project (README, step 3), or staging's chat refuses every message. How it's built: `src/server/AGENTS.md`, *The rate limit*. +- **Before it:** slice 28, the owner's interface review so far, is merged (0ca1d52, 2026-10-26): "Start over" asks in a pop-over under its button; the neutral palette of the owner's site, with `--prose` for replies and statements; the PRD's headings lighter; the first screen centred, with the households linking to their interviews and a glimpse of the workspace, arriving line by line once. How it's built: `src/features/AGENTS.md`, *"Start over" asks first*, *The palette* and *The intro*. +- **Before that:** slice 27 is merged (b5ba6c0, 2026-10-25), with the owner's fixes from testing it on the same pull request (28ab6ec to 07b17c0): working lines that stay steady on slow steps and retries, one "Thinking" line through an answer's steps, the search count that stays in view and glides, and replies typed out as opencode shows a streaming one (`docs/decisions.md`, Design, 2026-10-25). How it's built: `src/features/AGENTS.md`, *Desktop layout*, *The chat's column* and *A reply is typed out*. A local build or dev server can keep an old `globals.css` until `.next` is deleted (KI-11). - **The README's demo** (merged, a4d5574): `docs/media/prd-assistant-demo.gif` and its source copy `prd-assistant-demo.mp4`, a motion piece about the interface made in Remotion 4.0.538 in its own project, `D:\IT_Projects\Web Dev\prd-assistant-video`, outside the repository; its copy, colours and timings are in that project's `src/config.ts`. `npx remotion render PrdAssistantDemo out/prd-assistant-demo.mp4 --codec=h264 --crf=16 --image-format=png`, and for the GIF `--codec=gif --every-nth-frame=2 --scale=0.5`. The GIF's click opens the MP4 through GitHub's own video hosting. - **Next:** 1. The rest of the owner's interface review, if any, as its next piece, and the measurement lane's staging try of the merged chat, whose screenshots may bring changes. - 2. Slice 8, the rate limit, once 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; "Start over"'s pop-over under its button, on anchor positioning like the source's, else a centred box; the intro's arrival timed from the first paint, and its glimpse's fade at its foot; the chat's centred column and the PRD's narrower one; 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' arrival, on inline words' relative offsets and a list marker's colour, and the searches drawing together; the pop-over growing in on `@starting-style`, before which it opens at once, and closing the same way only where `overlay` keeps it on top (Chrome), elsewhere at once, by design; 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; what arrives, on `linear()` and `@property`, whose plain fallbacks are an ease-out and, without `@property`, a pane's rise and delay passing to what's in it). Motion on a phone and a slow machine (only headless Chromium on this one). On staging, how arrivals look at the real model's pace, 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 coming in word by word, 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 `docs/record-slice-28-merge` and its merge; the rest of the interface review, if any; whether `next dev` may keep adding its block to `AGENTS.md` (KI-8); the GitHub description and topics whenever they like. + 2. Slice 8's push and merge, after `SUPABASE_SECRET_KEY` is in Vercel's staging project, then a check on staging: the 11th message of the day refused. +- **Unverified:** on staging, the rate limit (Vercel's `x-forwarded-for`, the secret key there, the 11th message refused; locally only against the fake's 429 and the database); 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; "Start over"'s pop-over under its button, on anchor positioning like the source's, else a centred box; the intro's arrival timed from the first paint, and its glimpse's fade at its foot; the chat's centred column and the PRD's narrower one; 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' arrival, on inline words' relative offsets and a list marker's colour, and the searches drawing together; the pop-over growing in on `@starting-style`, before which it opens at once, and closing the same way only where `overlay` keeps it on top (Chrome), elsewhere at once, by design; 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; what arrives, on `linear()` and `@property`, whose plain fallbacks are an ease-out and, without `@property`, a pane's rise and delay passing to what's in it). Motion on a phone and a slow machine (only headless Chromium on this one). On staging, how arrivals look at the real model's pace, 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 coming in word by word, 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:** `SUPABASE_SECRET_KEY` in Vercel's staging project, then pushing `feat/rate-limit` and its merge; pushing `docs/record-slice-28-merge` and its merge; the rest of the interface review, if any; 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. @@ -65,12 +66,7 @@ ### 8a · The quota message · risk: normal · status: done (5267952) -### 8 · Rate limit · risk: risky (personal data, permissions) · status: todo -- **Goal:** one visitor can't use up the free quota. -- **Acceptance:** - - Each visitor gets 30 messages a day, recognised by a hashed IP address that's kept only for the limit window. The counts live in a table only the server's secret key reaches, with tests for each role. - - The 31st message in a day is refused with a friendly message, and no raw IP address is ever stored. - - Before starting, the owner decides the legal basis and the limit's size (see *Deferred*). +### 8 · Rate limit · risk: risky (personal data, permissions) · status: done (on `feat/rate-limit`; its id on `main` goes here after the merge) ### 9a · The measurement's recorder · risk: risky (secrets) · status: done (b339dbd) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index be7e57f..191a581 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,6 +1,7 @@ import { createUIMessageStreamResponse } from "ai"; -import type { ChatMessage } from "@/lib/chat"; +import { DAILY_LIMIT_REACHED, type ChatMessage } from "@/lib/chat"; import { ChatRequestError, readChatRequest, streamAnswer } from "@/server/chat"; +import { admitMessage } from "@/server/rate-limit"; // The chat's endpoint: the browser posts the conversation, and the answer // streams back as the AI SDK's UI message stream (server-sent events). @@ -30,6 +31,10 @@ export async function POST(request: Request) { throw error; } + // Only a request that reads as a conversation counts against the visitor's + // day (src/server/rate-limit.ts), and a refused one reaches no model. + if (!(await admitMessage(request))) return refuse(DAILY_LIMIT_REACHED, 429); + return createUIMessageStreamResponse({ // A visitor who leaves stops the model call, and its tokens with it. stream: streamAnswer(messages, { abortSignal: request.signal }), diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index ab0b395..cadaf3e 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -57,7 +57,7 @@ ## The intro (`workspace/intro.tsx`, `workspace/disclosure.ts`) - **Its form** (slice 28, `docs/decisions.md`, Design, 2026-10-26): centred, at most 48rem wide; the heading in weight 300; one short line; "Interviewed:" and the six names, each a link to `/transcripts#`, the interview's anchor on the transcripts page; the buttons; then `Glimpse`, an inline SVG of the chat beside the PRD in bars, `aria-hidden`, faded at its foot (`mask-b-from-40%`, Tailwind 4.1's mask utilities), `hidden sm:block`. The notices (a discarded PRD, a failed first request, the allocation) are left-aligned boxes in the centred column. At 1280×720 the glimpse ends inside the window (`tests/e2e/intro.spec.ts`). -- **Its arrival:** its lines are `.arrive` with `--order` 0 to 4, the glimpse `arrive-pane` and last, in the server's page as in the browser's. The browser's intro replaces the server's a moment after the first paint, which would start every arrival again; so `useArrivalSinceFirstPaint`, in a layout effect, sets each of its animations' `startTime` to the `first-contentful-paint` entry's `startTime`, both on the document's timeline (MDN, `Animation.startTime`, `Document.timeline` and `PerformancePaintTiming`, read 2026-10-26 through Context7). They go on where the server's page had got to, or are over, and an intro brought back later is simply there. Without a paint entry they run from now. On the dev server the first paint can come seconds after the page is asked for, so a screenshot then catches the arrival at its start. +- **Its arrival:** its lines are `.arrive` with `--order` 0 to 4, the glimpse `arrive-pane` and last, in the server's page as in the browser's. The browser's intro replaces the server's a moment after the first paint, which would start every arrival again; so `useArrivalSinceFirstPaint`, in a layout effect, sets each of its animations' `startTime` to the server's intro's, both on the document's timeline (MDN, `Animation.startTime`, `Document.timeline` and `PerformancePaintTiming`, read 2026-10-26 through Context7). They go on exactly where the server's page had got to, or are over, and an intro brought back later is simply there. React doesn't hydrate the server's intro: inside `use(browser())`'s boundary the browser renders its own in place of it, and removes the server's in that commit, before any effect or cleanup. So `serverArrivalStart` reads the server's heading's `getAnimations()` in a `useState` initializer, while the browser's intro first renders. Without it (arrivals not begun, a later intro) they count from the `first-contentful-paint` entry, and without that from now. The paint entry alone came 15–20ms after the server's arrivals began (a production build, 2026-10-26), so a swap early in the fade stepped the heading back, by 5.5% in one local run of five and in CI as a failure of `tests/e2e/intro.spec.ts`; with the server's start, 20 runs of 20 showed no step. On the dev server the first paint can come seconds after the page is asked for, so a screenshot then catches the arrival at its start. - **What shows when:** the intro until the visitor leaves it or the conversation holds messages (`conversationStarted`, which holds for a restored conversation too); the suggestion, "Add grocery delivery to the PRD.", once a section is saved, until the visitor has sent it (`offeredSuggestion`). Its button and the suggestion import their words from `src/lib/suggestions.ts`, never a copy: the measurement sends the intro's words too, and changing them means a new baseline. The suggestion itself isn't measured; the measurement's grocery delivery claim (`GROCERY_DELIVERY_CLAIM`) was the suggestion until 2026-10-22. - **A failed first request:** `useChat`'s `onFinish` runs after every answer, once the error status is set, with `isError` and the messages from the chat's own state (ai 7.0.143, `makeRequestImpl` in `node_modules/ai/dist/index.js`, read 2026-10-19; the AI SDK's `useChat` reference through Context7). `afterAnswer` brings the intro back only when the intro's request failed before a section was saved, and clears the error, so the chat never opens on the old one. The server can send an error after a save (a reply cut off at its output limit, or the provider failing on the last call); then the section stays, with the chat's "Try again". Don't decide this in `onError`: it runs before the status is set, and the rendered `messages` can lag behind the chat's state. A ref marks the intro's request, so a later failure stays in the chat. - **The message box's text lives in `Workspace`,** passed to `Chat`: the chat unmounts when the intro comes back, and what the visitor typed during a failed first request must survive. "Start over" leaves it, as before the intro. @@ -66,7 +66,7 @@ - **Kept out of `src/lib`:** the intro's logic lives here, not in `src/lib`, whose changes the measurement's comparison reports as code that shapes the model (`src/server/measurement/report.ts`). ## When the AI can't answer (`workspace/quota.tsx`) -- **The used-up allocation:** `useChat`'s `onError` (`noteQuota`) sets `quotaUsedUp` when the error's message is `QUOTA_USED_UP` from `src/lib/chat.ts`, the server's words for Cloudflare's quota 429 (`src/server/AGENTS.md`). It lasts until a reload, through "Start over" too. The intro then shows `QuotaNotice` instead of its button and "Or ask your own question first"; the chat shows it instead of the suggestion, the message box and "Try again"; the PRD drops its check-in, which sends a message too. The conversation, the PRD and the link to the interviews stay. +- **The used-up allocation and the visitor's used-up day:** `useChat`'s `onError` (`noteDayOver`) sets `dayOver` from the error's message (`dayOverIn`): "quota" for `QUOTA_USED_UP`, the server's words for Cloudflare's quota 429, and "limit" for `DAILY_LIMIT_REACHED`, the chat route's 429 once the visitor has sent the day's messages, which the AI SDK makes the error's message (`createUIApiCallError` in `node_modules/ai/dist/index.js`); both are in `src/lib/chat.ts` (`src/server/AGENTS.md`, *The rate limit*). Both end at 00:00 UTC, so they share `QuotaNotice`, which words each its own way. It lasts until a reload, through "Start over" too. The intro then shows `QuotaNotice` instead of its button and "Or ask your own question first"; the chat shows it instead of the suggestion, the message box and "Try again"; the PRD drops its check-in, which sends a message too. The conversation, the PRD and the link to the interviews stay. - **The reset time:** 00:00 UTC, and the same moment in the visitor's locale and time zone (`nextReset`, `localTime`), computed in the browser when the notice first shows, so the server's render never holds a time. - **Busy:** an error whose message is `DEMO_BUSY` shows those words with "Try again", since a minute later it can work. Every other error keeps the generic sentence. diff --git a/src/features/chat/chat.tsx b/src/features/chat/chat.tsx index ec94d1d..7257cf1 100644 --- a/src/features/chat/chat.tsx +++ b/src/features/chat/chat.tsx @@ -11,7 +11,7 @@ import { } from "react"; import { useArrival } from "@/components/arrival"; import { Button } from "@/components/ui/button"; -import { QuotaNotice } from "@/features/workspace/quota"; +import { QuotaNotice, type DayOver } from "@/features/workspace/quota"; import { DEMO_BUSY, MAX_MESSAGE_LENGTH, type ChatMessage } from "@/lib/chat"; import type { SectionName } from "@/lib/prd"; import { cn } from "@/lib/utils"; @@ -52,16 +52,17 @@ function followEnd(element: HTMLElement) { /** * 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. - * Once the day's free AI allocation is used up, the conversation stays to - * read, and a notice takes the place of the suggestion and the message box. - * What appears after the chat showed arrives (`useArrival`): the failure - * notice, the suggestion, the allocation's notice and Jump to latest. + * Once the day's free AI allocation or the visitor's messages are used up, + * the conversation stays to read, and a notice takes the place of the + * suggestion and the message box. What appears after the chat showed arrives + * (`useArrival`): the failure notice, the suggestion, that notice and Jump to + * latest. */ export function Chat({ messages, status, error, - quotaUsedUp, + dayOver, suggestion, draft, onDraftChange, @@ -75,8 +76,11 @@ export function Chat({ status: ChatStatus; /** Why the last answer failed, in the server's words. */ error: Error | undefined; - /** The day's free AI allocation is used up, so nothing can be sent. */ - quotaUsedUp: boolean; + /** + * Nothing can be sent until 00:00 UTC: the day's free AI allocation is used + * up, or the visitor has sent the day's messages. + */ + dayOver: DayOver | undefined; /** A message the visitor can send with one click. */ suggestion: string | undefined; /** The message box's text, kept by the page so it outlasts the chat. */ @@ -103,10 +107,10 @@ export function Chat({ const working = status === "submitted" || status === "streaming"; const canSend = !working && draft.trim() !== ""; - const failed = status === "error" && !quotaUsedUp; - const offered = quotaUsedUp ? undefined : suggestion; + const failed = status === "error" && !dayOver; + const offered = dayOver ? undefined : suggestion; const failureArrives = useArrival(failed); - const quotaArrives = useArrival(quotaUsedUp); + const dayOverArrives = useArrival(dayOver); const suggestionArrives = useArrival(offered); // The chat follows the newest message, unless the visitor scrolled up. @@ -182,8 +186,8 @@ export function Chat({ setAwayFromEnd(false); // The button goes with the click, so focus moves on first, to where the // visitor goes on: the message box, or the chat's heading once the day's - // allocation is used up and the box is gone. - if (quotaUsedUp) heading.current?.focus(); + // allocation or the visitor's messages are used up and the box is gone. + if (dayOver) heading.current?.focus(); else box.current?.focus(); scrollToEnd(element); } @@ -279,9 +283,10 @@ export function Chat({ )} - {quotaUsedUp && ( + {dayOver && ( )} @@ -309,7 +314,7 @@ export function Chat({ )}