Skip to content
Merged
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,32 @@

### Fixed

- **The phone drawer keeps Tab inside it,** as its `aria-modal` promised, and
hides the chat behind it from screen readers (`aria-hidden`; `inert` would
blur the button that opened it, and focus couldn't return there). The trap
is shared with the Sources sheet (`features/chat/focus.js`).
- **A question sent while scrolled up is shown,** and the view follows its
answer again. Scrolling up while it streams still stops the following.
- **A finished answer is announced to screen readers:** "Answer ready, N
sources" (one per cited document, as the seals count them), from a status
region that stays mounted. The streaming region was removed at `done`, so
nothing was said. History never announces.
- **A connection lost mid-answer says PolicyPal can't be reached,** not the
browser's raw error, and `apiStream` always cancels its reader, so the
connection is let go however the stream ends. An abort is still rethrown
as itself.
- ADR 0027 notes that its timeout bound left out the CMS pricing call: at
most 8 s in each of two tool rounds, so the theoretical bound passes
Azure's 240 s ingress timeout.
- Renaming a thread checks the 1 to 200 characters after trimming, as ADR
0027 says. A title that fits once its spaces are removed was refused.
- **A citation keeps its seal number after a reload.** A streamed answer
listed its sources in retrieval order, and a reload in relevance order, so
the numbers could change. Both now list them most relevant first, ties by
chunk and label compared byte-wise (`COLLATE "C"` in SQL).
- The test of a streamed answer whose save fails to commit now runs the real
spend recording. It checks that the day's total is the spend once (not 0,
not doubled) and that no answer or citation is left behind.
- **A streamed answer's spend survives a failed save.** Spend is marked as
recorded only after its commit succeeds. Before, a rolled-back commit took
the spend with it, and it was never recorded again.
Expand Down Expand Up @@ -754,6 +780,9 @@

### Security

- The database engine hides bound values (`hide_parameters=True`), so an
email, question or answer never appears in a SQL error or its log line.
Postgres's own message can still quote a value it failed to parse.
- **A streamed round that never reports its usage is counted by estimate:**
the whole prompt plus the output cap. This covers a client that
disconnects, Stop, or a dropped connection. Without it, ending streams
Expand Down
6 changes: 6 additions & 0 deletions docs/decisions/0027-streaming-answers-and-cited-passages.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ It amends two earlier ADRs:
Sub-project 3 of the California expansion, which had planned to take this
number, takes ADR 0028 instead.

Corrected on 2026-09-27: the timeout bound under Consequences counts only
the model calls, which with retries alone reach 240 s. Each of the two tool
rounds can add a live CMS pricing call of up to 8 s
(`cms_live_timeout_seconds`), so the theoretical bound is past Azure's
ingress timeout, not close to it. The usual worst case is still well under it.

## Context

The chat worked, but three things made it read like a prototype:
Expand Down
22 changes: 15 additions & 7 deletions frontend/src/features/chat/AppShell.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { FOCUSABLE, trapTab } from "./focus";
import "./shell.css";

const SIDEBAR_WIDTH = 276;
const FOCUSABLE = 'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])';

// The phone's sidebar: a drawer over the chat, with focus moved into it and
// returned when it closes.
// The phone's sidebar: a drawer over the chat, with focus moved into it, kept
// there, and returned when it closes.
function Drawer({ children, onClose }) {
const ref = useRef(null);

Expand Down Expand Up @@ -36,9 +36,12 @@ function Drawer({ children, onClose }) {
aria-modal="true"
aria-label="Your questions"
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.stopPropagation(); // not also the page's Esc
onClose();
if (event.key === "Escape") {
event.stopPropagation(); // not also the page's Esc
onClose();
} else {
trapTab(event, ref.current);
}
}}
initial={{ x: "-100%" }}
animate={{ x: 0 }}
Expand Down Expand Up @@ -75,7 +78,12 @@ export default function AppShell({ sidebar, main, panel = null, sidebarHidden =
{sidebar}
</motion.div>
)}
{main}
{/* Behind an open drawer the chat is hidden, as aria-modal says; the
drawer keeps Tab. Not inert: that would blur the button that opened
the drawer before the drawer could note it, and focus couldn't go back. */}
<div className="shell-main" aria-hidden={(phone && drawerOpen) || undefined}>
{main}
</div>
<AnimatePresence>{panel}</AnimatePresence>
</div>
);
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/features/chat/AppShell.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe("AppShell", () => {
expect(container.firstChild).toHaveClass("collapsed");
expect(screen.queryByRole("complementary", { name: "Your questions" })).not.toBeInTheDocument();
expect(container.querySelector(".side-wrap")).toHaveAttribute("inert");
expect(container.querySelector(".shell-main")).not.toHaveAttribute("aria-hidden");
});

describe("on a phone", () => {
Expand Down Expand Up @@ -66,6 +67,47 @@ describe("AppShell", () => {
expect(onCloseDrawer).toHaveBeenCalledTimes(2);
});

it("keeps Tab inside the open drawer, and the chat behind it out of reach", async () => {
const twoButtons = (
<aside aria-label="Your questions">
<button type="button">New question</button>
<button type="button">Profile</button>
</aside>
);
const chat = (
<main>
<button type="button">Send</button>
</main>
);
const { container } = render(<AppShell sidebar={twoButtons} main={chat} drawerOpen onCloseDrawer={vi.fn()} />);
const drawer = screen.getByRole("dialog", { name: "Your questions" });

for (let i = 0; i < 4; i += 1) {
await userEvent.tab();
expect(drawer).toContainElement(document.activeElement);
}
await userEvent.tab({ shift: true });
expect(drawer).toContainElement(document.activeElement);
expect(container.querySelector(".shell-main")).toHaveAttribute("aria-hidden", "true");
});

it("gives the chat back, and focus to what opened the drawer, once it closes", async () => {
const opener = (
<main>
<button type="button">Open your questions</button>
</main>
);
const { container, rerender } = render(<AppShell sidebar={sidebar} main={opener} drawerOpen={false} onCloseDrawer={vi.fn()} />);
screen.getByRole("button", { name: "Open your questions" }).focus();

rerender(<AppShell sidebar={sidebar} main={opener} drawerOpen onCloseDrawer={vi.fn()} />);
expect(screen.getByRole("button", { name: "New question" })).toHaveFocus();
rerender(<AppShell sidebar={sidebar} main={opener} drawerOpen={false} onCloseDrawer={vi.fn()} />);

await waitFor(() => expect(screen.getByRole("button", { name: "Open your questions" })).toHaveFocus());
expect(container.querySelector(".shell-main")).not.toHaveAttribute("aria-hidden");
});

it("removes the drawer once closed", async () => {
const { rerender } = render(<AppShell sidebar={sidebar} main={main} drawerOpen onCloseDrawer={vi.fn()} />);
rerender(<AppShell sidebar={sidebar} main={main} drawerOpen={false} onCloseDrawer={vi.fn()} />);
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/features/chat/focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// What Tab can reach in the chat's modal layers: the phone drawer and the
// Sources sheet.
export const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])';

// Tab and Shift+Tab stay inside a modal layer, wrapping at either end. Moved
// by hand, since focus can sit on an element that isn't itself in the tab
// order (a selected source card).
export function trapTab(event, container) {
if (event.key !== "Tab") return;
const focusable = [...container.querySelectorAll(FOCUSABLE)];
if (focusable.length === 0) return;
const current = document.activeElement;
const position = (el) => current.compareDocumentPosition(el);
const after = focusable.filter((el) => el !== current && position(el) & Node.DOCUMENT_POSITION_FOLLOWING);
const before = focusable.filter((el) => el !== current && position(el) & Node.DOCUMENT_POSITION_PRECEDING);
const next = event.shiftKey ? (before.at(-1) ?? focusable.at(-1)) : (after[0] ?? focusable[0]);
event.preventDefault();
next.focus();
}
53 changes: 53 additions & 0 deletions frontend/src/features/chat/focus.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { trapTab } from "./focus";

function layer(html) {
const container = document.createElement("div");
container.innerHTML = html;
document.body.append(container);
return container;
}

function tab(container, shiftKey = false) {
const event = { key: "Tab", shiftKey, preventDefault: vi.fn() };
trapTab(event, container);
return event;
}

afterEach(() => {
document.body.innerHTML = "";
});

describe("trapTab", () => {
it("wraps from the last element to the first, and back with Shift", () => {
const container = layer('<button>One</button><input aria-label="Two" /><a href="#">Three</a>');
const [one, , three] = container.querySelectorAll("button, input, a");

three.focus();
expect(tab(container).preventDefault).toHaveBeenCalled();
expect(one).toHaveFocus();

tab(container, true);
expect(three).toHaveFocus();
});

it("moves on from an element outside the tab order, such as a selected card", () => {
const container = layer('<button>One</button><div tabindex="-1">Card</div><button>Two</button>');
container.querySelector("div").focus();

tab(container);
expect(container.querySelectorAll("button")[1]).toHaveFocus();
});

it("skips disabled controls and leaves other keys alone", () => {
const container = layer("<button>One</button><button disabled>Off</button><button>Two</button>");
const [one, , two] = container.querySelectorAll("button");
one.focus();

tab(container);
expect(two).toHaveFocus();
const other = { key: "Enter", preventDefault: vi.fn() };
trapTab(other, container);
expect(other.preventDefault).not.toHaveBeenCalled();
});
});
6 changes: 6 additions & 0 deletions frontend/src/features/chat/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
grid-template-columns: minmax(0, 1fr);
}

/* Only there to hide the main column behind the phone drawer: its child
stays the grid item. */
.shell-main {
display: contents;
}

.shell-error {
display: flex;
flex-direction: column;
Expand Down
18 changes: 1 addition & 17 deletions frontend/src/features/chat/sources/SourcesPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,10 @@ import { useEffect, useRef } from "react";
import IconButton from "../../../components/IconButton";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
import { sourceNumbers } from "../../../utils/markers";
import { trapTab } from "../focus";
import SourceCard from "./SourceCard";
import "./sources.css";

const FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';

// Tab and Shift+Tab stay inside a modal sheet, wrapping at either end. Moved
// by hand, since focus can sit on a card that isn't itself in the tab order.
function trapTab(event, container) {
if (event.key !== "Tab") return;
const focusable = [...container.querySelectorAll(FOCUSABLE)];
if (focusable.length === 0) return;
const current = document.activeElement;
const position = (el) => current.compareDocumentPosition(el);
const after = focusable.filter((el) => el !== current && position(el) & Node.DOCUMENT_POSITION_FOLLOWING);
const before = focusable.filter((el) => el !== current && position(el) & Node.DOCUMENT_POSITION_PRECEDING);
const next = event.shiftKey ? (before.at(-1) ?? focusable.at(-1)) : (after[0] ?? focusable[0]);
event.preventDefault();
next.focus();
}

// The cited passages of one answer (Main.dc.html's panel): a column beside the
// transcript, or a bottom sheet with a scrim at 720 px and below (Mobile.dc.html).
export default function SourcesPanel({ message, selectedSourceId, hoveredSourceId, onSelect, onClose }) {
Expand Down
26 changes: 24 additions & 2 deletions frontend/src/features/chat/transcript/Transcript.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import ErrorBanner from "../../../components/ErrorBanner";
import { toExchanges } from "../../../utils/exchanges";
import { sourceNumbers } from "../../../utils/markers";
import EmptyState from "./EmptyState";
import Exchange from "./Exchange";
import JumpToLatest from "./JumpToLatest";
Expand All @@ -11,6 +12,17 @@ const PINNED_PX = 80;
// Further than this above it, "Jump to latest" appears (spec §4.3).
const JUMP_PX = 200;

// What a screen reader hears when an answer finishes: the streaming region is
// replaced by the saved answer, which announces nothing. Sources are counted
// as the seals number them, one per document.
function readyText(messages, finishedId) {
const answer = finishedId && messages.find((m) => m.id === finishedId);
if (!answer) return "";
const count = sourceNumbers(answer.sources).size;
if (count === 0) return "Answer ready";
return `Answer ready, ${count} ${count === 1 ? "source" : "sources"}`;
}

function Content({ messages, status, error, onPrompt, onRetry, children }) {
if (status === "loading" && messages.length === 0) {
return (
Expand Down Expand Up @@ -71,10 +83,15 @@ export default function Transcript({
endRef.current?.scrollIntoView({ block: "end", behavior: still ? "auto" : "smooth" });
}

// The scroll position is the DOM's: follow new text only while pinned.
// The scroll position is the DOM's: follow new text only while pinned. A
// question just sent (a draft appearing) is always shown, and pins the view
// again, wherever the reader had scrolled to.
const drafting = useRef(false);
useEffect(() => {
if (live && !drafting.current) pinned.current = true;
drafting.current = Boolean(live);
if (pinned.current) endRef.current?.scrollIntoView({ block: "end" });
}, [messages.length, live?.text, live?.stages.length, live?.notices.length]);
}, [messages.length, live]);

return (
<>
Expand Down Expand Up @@ -103,6 +120,11 @@ export default function Transcript({
<div className="transcript-end" ref={endRef} />
</div>
{away && <JumpToLatest onClick={jump} />}
{/* Always mounted, and empty while an answer streams, so the next
answer is announced even when its text is the same as the last. */}
<p className="visually-hidden" role="status">
{live ? "" : readyText(messages, finishedId)}
</p>
</>
);
}
47 changes: 47 additions & 0 deletions frontend/src/features/chat/transcript/Transcript.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,51 @@ describe("Transcript", () => {
rerender(<Transcript {...props} live={{ ...live, text: "It's the amount" }} />);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
});

it("announces a finished answer with its count of cited documents", () => {
const sources = [
{ id: "s1", source: "Plan A - Summary of Benefits.pdf", chunk_id: "c1", relevance: 0.9 },
{ id: "s2", source: "Plan A - Summary of Benefits.pdf", chunk_id: "c2", relevance: 0.8 },
{ id: "s3", source: "wiki_Copayment.txt", chunk_id: "c3", relevance: 0.7 },
];
const cited = [messages[0], { ...messages[1], sources }];
const { rerender } = renderTranscript({ messages: cited });
const status = () => screen.getByRole("status");
expect(status()).toBeEmptyDOMElement(); // history never announces

rerender(<Transcript messages={cited} status="ready" finishedId="a1" onOpenSource={vi.fn()} />);
expect(status()).toHaveTextContent("Answer ready, 2 sources");
});

it("says just that the answer is ready when it cites nothing, and stays quiet while one streams", () => {
const live = { questionId: "q2", stages: ["writing"], notices: [], text: "It" };
const { rerender } = renderTranscript({ finishedId: "a1" });
const status = () => screen.getByRole("status");
expect(status()).toHaveTextContent(/^Answer ready$/);

const asked = [...messages, { id: "q2", role: "user", content: "And a copay?", created_at: "2026-01-01T00:01:00Z" }];
rerender(<Transcript messages={asked} status="ready" live={live} finishedId="a1" onOpenSource={vi.fn()} />);
expect(status()).toBeEmptyDOMElement();
});

it("shows a question you send even while scrolled up, then follows its answer", () => {
const scrollIntoView = vi.fn();
const props = { messages, status: "ready", live: null, onOpenSource: vi.fn() };
const { container, rerender } = render(<Transcript {...props} />);
container.querySelector(".transcript-end").scrollIntoView = scrollIntoView;
const scroller = container.querySelector(".scroll");
scrollTo(scroller, { top: 100 });

const question = { id: "temp-1", role: "user", content: "And a copay?", created_at: "2026-01-01T00:01:00Z" };
const live = { questionId: "temp-1", stages: [], notices: [], text: "" };
rerender(<Transcript {...props} messages={[...messages, question]} live={live} />);
expect(scrollIntoView).toHaveBeenCalledTimes(1);

rerender(<Transcript {...props} messages={[...messages, question]} live={{ ...live, text: "A copay" }} />);
expect(scrollIntoView).toHaveBeenCalledTimes(2);

scrollTo(scroller, { top: 100 }); // scrolling up again still stops the following
rerender(<Transcript {...props} messages={[...messages, question]} live={{ ...live, text: "A copay is" }} />);
expect(scrollIntoView).toHaveBeenCalledTimes(2);
});
});
3 changes: 2 additions & 1 deletion frontend/src/pages/ChatPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ describe("ChatPage", () => {
await user.type(screen.getByRole("textbox", { name: "Thread name" }), " 101{Enter}");

expect(renameThread).toHaveBeenCalledWith("t1", "Deductibles 101");
expect(await screen.findByRole("status")).toHaveTextContent("Couldn't rename this thread. Try again.");
// By its text: the transcript keeps a status region of its own.
expect(await screen.findByText("Couldn't rename this thread. Try again.")).toHaveAttribute("role", "status");
});

it("renames the open thread from its title", async () => {
Expand Down
Loading
Loading