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
1 change: 1 addition & 0 deletions src/features/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- **Tables** (slice 19, `chat/rich-text.tsx`; the parser in `src/lib/markdown.ts`, which only the browser uses): opencode's at 50c5fce (`docs/decisions.md`, Design, 2026-10-20): a real `<table>`, its header's named cells `<th scope="col">` in medium weight over `border-border-strong`, rows over `border-border`, both `0.5px` (Chromium draws 1px on a screen of 1x), cells `p-3 align-top`. Each cell is `min-w-36`, which Chromium honours on table cells, so a wide table scrolls rather than squeezing its columns to a word per line. Every row is padded to the widest row or the header with empty `<td>`s, so the hairlines run the full width; a header's empty name is a `<td>` too, as it names nothing, and a row or header of nothing but empty cells is dropped by the parser. The table sits in `SideScroll`, an `overflow-x-auto` box that a `ResizeObserver` measures, on itself and on the table: while the table is wider, the box is `role="region"`, named "Table, scrolls sideways", with `tabIndex={0}`, so the keyboard reaches it (axe's `scrollable-region-focusable`, which checks only a box that overflows); a table that fits adds no tab stop. A hidden Browser pane delivers the observer's first measure only when it next draws. The cells' runs are built in `RichText`'s render, not inside `SideScroll`, so the fade's character count (`words.at`) keeps the rendered order. The fake model writes no table, so `tests/e2e/chat.spec.ts` opens a kept conversation that holds two.
- **Stop** (slice 12): while the assistant works, the message box's Send button is Stop, one `<button>` whose label and type change, so focus stays on it; an empty Send is `aria-disabled`, not `disabled`, which would drop focus to the page. A Stop click calls `preventDefault()`: `useChat`'s `stop()` can end the answer and turn the button back into a submit button before the browser's default action, which then sent the draft (found 2026-10-20 by `tests/e2e/chat.spec.ts`; jsdom's `fireEvent` doesn't show it). `stop()` aborts the request, sets the status to `ready` and calls `onFinish` with `isAbort` (ai 7.0.143, `AbstractChat.stop` and `makeRequest` in `node_modules/ai/dist/index.js`, read 2026-10-20; the AI SDK's *Stopping Streams*, through Context7); `afterAnswer` marks the question as stopped only when `stopRequested` says the visitor pressed Stop, since "Start over" aborts too. The route hands the cancel to the model call (`request.signal`), so no further model calls follow; the call in flight is a whole reply, which Cloudflare may still finish and count.
- **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.
- **Under the message box** (the owner, 2026-10-29): the model's name as visitors see it, `site.model.name` ("OpenAI open model", not the slug), linked to the exact model's page, then a line that it's a free model that can make mistakes and follows short, specific requests best; under that, the demo notice, `#chat-notice`, the box's description. `tests/unit/providers.test.ts` holds the link to `CHAT_MODEL`, so a model change fails it until the name and link follow.
- **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. 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.
Expand Down
21 changes: 17 additions & 4 deletions src/features/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
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 { site } from "@/lib/site";
import { cn } from "@/lib/utils";
import { Conversation } from "./conversation";

Expand Down Expand Up @@ -361,10 +362,22 @@ export function Chat({
{working ? "Stop" : "Send"}
</Button>
</div>
<p id="chat-notice" className="text-sm text-muted-foreground">
This is a demo with fictional interviews. Don&apos;t enter personal
information.
</p>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<a
href={site.model.page}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
{site.model.name}
</a>{" "}
· A free model: it can make mistakes, and follows short, specific
requests best.
</p>
<p id="chat-notice">
This is a demo with fictional interviews. Don&apos;t enter personal
information.
</p>
</div>
</form>

<p role="status" className="sr-only">
Expand Down
9 changes: 9 additions & 0 deletions src/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ export const site = {
links: {
repository: "https://github.com/TechNomadCode/prd-assistant",
},
/**
* The chat model as visitors see it under the message box: a name that
* says what it is, linked to the page of the exact model in
* `src/server/ai/models.ts`.
*/
model: {
name: "OpenAI open model",
page: "https://developers.cloudflare.com/workers-ai/models/gpt-oss-120b/",
},
} as const;

/**
Expand Down
18 changes: 18 additions & 0 deletions tests/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,24 @@ test.describe("chat", () => {
).toBeVisible();
});

test("names the model under the message box and says it can make mistakes", async ({
page,
}) => {
await openChat(page);

await expect(
page.getByRole("link", { name: "OpenAI open model", exact: true }),
).toHaveAttribute(
"href",
"https://developers.cloudflare.com/workers-ai/models/gpt-oss-120b/",
);
await expect(
page.getByText(
"A free model: it can make mistakes, and follows short, specific requests best.",
),
).toBeVisible();
});

test("marks an answer that breaks off as unfinished, and trying again replaces it", async ({
page,
}, testInfo) => {
Expand Down
8 changes: 8 additions & 0 deletions tests/unit/providers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { site } from "@/lib/site";
import { FAKE_CHAT_MODEL } from "@/server/ai/fake-chat-model";
import { CHAT_MODEL, EMBEDDING_MODEL } from "@/server/ai/models";
import { createChatModel, createEmbedder } from "@/server/ai/providers";
Expand Down Expand Up @@ -91,6 +92,13 @@ describe("choosing the chat model", () => {
expect(createChatModel(cloudflare).modelId).toBe("@cf/openai/gpt-oss-120b");
});

it("links the model name visitors see to the page of the model the chat runs on", () => {
const slug = CHAT_MODEL.split("/").at(-1);
expect(site.model.page).toBe(
`https://developers.cloudflare.com/workers-ai/models/${slug}/`,
);
});

it.each([["mistral"], ["openai"], ["Fake"], [undefined]])(
"rejects an unknown adapter name: %s",
(provider) => {
Expand Down
Loading