Add a browser example - #97
Conversation
Add examples/05-browser: a Vite + vanilla TypeScript chat page that runs the agent loop entirely in the browser — streaming into the DOM, client-side tools that reach browser APIs, and session persistence in localStorage. The example's build script runs vite build, so the workspace build gate keeps verifying that the runtime-agnostic packages bundle for a browser target. Closes #96 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new examples/05-browser Vite + vanilla TypeScript demo that runs the full agent loop in a browser (streaming updates into the DOM, browser-native tools, and session/transcript persistence via localStorage) to validate that the published packages bundle for a browser target, addressing issue #96.
Changes:
- Introduces
examples/05-browserwith a simple chat UI, browser tools (set_theme,get_local_time), andlocalStoragepersistence. - Updates
examples/README.mdto document the new browser example. - Updates
pnpm-lock.yamlto include the new example’s dependencies and reflect the Vite version used by the workspace.
File summaries
| File | Description |
|---|---|
| pnpm-lock.yaml | Adds the new example importer and updates lockfile entries for the workspace dependency graph. |
| examples/README.md | Documents the new browser example and how to run it. |
| examples/05-browser/tsconfig.json | Sets up a browser-targeted TS config (neutral libs) for the example. |
| examples/05-browser/src/style.css | Adds styling for the browser chat UI and themes. |
| examples/05-browser/src/main.ts | Implements the in-browser agent loop, streaming rendering, browser tools, and persistence logic. |
| examples/05-browser/README.md | Explains what the example demonstrates and includes API-key safety guidance. |
| examples/05-browser/package.json | Defines the example’s Vite dev/build scripts and dependencies. |
| examples/05-browser/index.html | Provides the chat UI markup and loads the example entrypoint. |
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
- Files reviewed: 7/8 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Turn examples/05-browser into a multi-page Vite app: the existing chat page, a canvas page whose drawing primitives are client-side tools, a structured-output page that fills the page's fields from the typed response.value, and an Anthropic page that swaps AnthropicChatClient into the otherwise identical chat code. Shared DOM and storage helpers move to src/ui.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Lite
Label the chat, drawing and extraction inputs for screen readers, recover from an unrestorable saved session by starting a fresh one, and skip persisting an assistant transcript entry when a run produced no text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
examples/05-browser/src/anthropic.ts:6
- The doc comment says the page code is identical to the OpenAI chat page except for client construction, but this implementation omits the OpenAI page’s localStorage transcript/session persistence and Clear button flow. Either bring this page in sync with main.ts or adjust the comment to avoid claiming identical code.
* `AnthropicChatClient` is a `ChatClient` like any other, so the page code is identical to the
* OpenAI chat — only the client construction differs. The Anthropic SDK has the same explicit
* browser opt-in as OpenAI's, and CORS on the Anthropic API allows direct calls from a page.
examples/README.md:89
- The Browser section says the Anthropic page code is otherwise identical to the chat page, but the Anthropic page omits chat persistence (localStorage transcript/session) and the Clear button. Please adjust this row or bring the Anthropic page in sync so this summary is accurate.
| `/anthropic.html` | The chat page with `AnthropicChatClient` swapped in; the page code is otherwise identical |
examples/05-browser/anthropic.html:22
- This warning text claims “the rest of the code is identical”, but unlike the OpenAI chat page this Anthropic page does not include the localStorage transcript/session persistence (or the Clear button). Update the copy or align the implementation so the statement is true.
This page is the chat example with <code>AnthropicChatClient</code> swapped in — the rest
of the code is identical. Keys pasted here stay in memory — in production, run agents
server-side.
examples/05-browser/README.md:17
- This row says the Anthropic page is identical to the chat page aside from the client swap, but the Anthropic page currently lacks the chat page’s localStorage transcript/session persistence and Clear button. Please tweak the wording or align the page implementation.
| [`/anthropic.html`](anthropic.html) — Anthropic | The chat page with `AnthropicChatClient` swapped in — the rest of the code is identical, which is the point |
- Files reviewed: 15/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
…page The page intentionally omits the chat page's localStorage persistence and Clear button, so the docs no longer call the code identical: the agent, tools and streaming code are what carries over unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (5)
Previously missed (5) — in code that hasn't changed since the last review.
examples/05-browser/index.html:39
#logis marked as a live region, but the chat page streams by repeatedly updating the assistant bubble’s text. That can cause screen readers to announce partial chunks as they arrive. Consider limiting announcements to newly added bubbles by settingaria-relevant="additions"(and optionallyrole="log").
<section id="log" aria-live="polite"></section>
examples/05-browser/canvas.html:39
#logis a live region, but this page streams updates by mutating existing bubble text. Screen readers may announce each incremental mutation. Settingaria-relevant="additions"(androle="log") keeps announcements to newly appended entries instead.
<section id="log" aria-live="polite"></section>
examples/05-browser/structured.html:45
#logis a live region; while this page doesn’t stream text, it shares the same DOM structure as the streaming pages. Addingaria-relevant="additions"(androle="log") makes the behavior consistent and avoids announcing incremental text mutations if this page ever adds streaming or progressive updates.
<section id="log" aria-live="polite"></section>
examples/05-browser/tsconfig.json:7
vite.config.tsis TypeScript but isn’t included in the example’stscproject, sopnpm --filter example-05-browser typecheckwon’t catch type errors in the Vite config. Consider adding it toincludeso it’s typechecked along withsrc/.
"compilerOptions": {
"isolatedDeclarations": false,
"noEmit": true
},
"include": ["src/**/*.ts"]
examples/05-browser/vite.config.ts:2
- If
vite.config.tsis added to the project’stscinclude, it will need Node types to resolvenode:pathandimport.meta.dirname. A file-scoped/// <reference types="node" />keeps the appsrc/Node-free while still typechecking the config.
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Lite
Streaming mutates the assistant bubble's text in place, which a plain polite live region re-announces chunk by chunk. role="log" with aria-relevant="additions" limits announcements to newly added entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
examples/05-browser/src/main.ts:136
- Streaming text is appended via
reply.textContent += update.text, which re-serializes the element’s full text content on every chunk and can get expensive for long/fast streams. Appending the chunk as a text node avoids repeatedly rewriting the entire element.
if (update.text !== '') {
reply ??= bubble(log, 'assistant');
reply.textContent += update.text;
reply.scrollIntoView({ block: 'end' });
examples/05-browser/src/anthropic.ts:100
- Streaming text is appended via
reply.textContent += update.text, which rewrites the whole element text on every chunk and can be unnecessarily expensive for long/fast streams. Appending the chunk avoids repeated full rewrites.
if (update.text !== '') {
reply ??= bubble(log, 'assistant');
reply.textContent += update.text;
reply.scrollIntoView({ block: 'end' });
examples/05-browser/src/canvas.ts:150
- Streaming text is appended via
reply.textContent += update.text, which rewrites the whole element text on every chunk and can be unnecessarily expensive for long/fast streams. Appending the chunk avoids repeated full rewrites.
if (update.text !== '') {
reply ??= bubble(log, 'assistant');
reply.textContent += update.text;
reply.scrollIntoView({ block: 'end' });
- Files reviewed: 15/16 changed files
- Comments generated: 1
- Review effort level: Lite
Append each streamed chunk as a text node instead of rewriting the bubble's whole text content per chunk, and scroll newly added tool chips into view so tool-only turns stay visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
examples/05-browser/src/main.ts:58
- The transcript restored from localStorage is assumed to match
TranscriptEntry[]. If the stored JSON is corrupted or has an unexpected shape (but still parses), this can renderundefinedbubbles or odd classes instead of failing gracefully. Consider validating the parsed value and clearing the stored transcript when it’s not an array of{ role, text }entries.
// The session is the source of truth the model sees; the transcript only redraws past bubbles.
const transcript: TranscriptEntry[] = loadJson<TranscriptEntry[]>(TRANSCRIPT_KEY) ?? [];
for (const entry of transcript) {
bubble(log, entry.role, entry.text);
}
if (transcript.length > 0) {
chip(log, 'conversation restored from localStorage');
}
examples/05-browser/src/main.ts:122
- The Clear button remains enabled while a send is in progress. If the user clears mid-stream, the in-flight loop can repopulate the transcript/session (and re-save them) after the clear, leaving storage and UI in an unexpected state. Disabling Clear while sending avoids this race in the example.
This issue also appears on line 147 of the same file.
promptInput.value = '';
sendButton.disabled = true;
bubble(log, 'user', text);
transcript.push({ role: 'user', text });
examples/05-browser/src/main.ts:150
- Follow-up to disabling Clear while sending: re-enable it in the
finallyblock so the UI doesn’t get stuck after a run completes or fails.
} finally {
sendButton.disabled = false;
promptInput.focus();
}
- Files reviewed: 15/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
Validate the transcript restored from localStorage and drop it when it is not the shape this page writes, and disable the Clear button while a send is in flight so clearing cannot race the streaming loop that repopulates the log and storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
examples/05-browser/package.json:24
vitedeclares a peer dependency on@types/node(see pnpm-lock.yaml vite@8.2.2 peerDependencies). Adding it here avoids relying on hoisted/workspace-provided types and matches the other examples’ devDependency setup.
"devDependencies": {
"typescript": "catalog:",
"vite": "^8.2.2"
}
- Files reviewed: 15/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
Vite lists @types/node as an optional peer; declaring it matches the other examples instead of relying on workspace hoisting. The example's sources still typecheck without Node types via the neutral tsconfig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
examples/05-browser/src/main.ts:159
- Streaming text is appended as a new text node for every chunk (
reply.append(update.text)), which can create hundreds/thousands of DOM nodes for long responses and slow the page. Consider appending once, then mutating the existing trailing Text node for subsequent chunks.
if (update.text !== '') {
reply ??= bubble(log, 'assistant');
reply.append(update.text);
reply.scrollIntoView({ block: 'end' });
examples/05-browser/src/canvas.ts:150
- Streaming text is appended as a new text node for every chunk (
reply.append(update.text)), which can create many DOM nodes for long responses and degrade performance. Consider appending once, then mutating the existing trailing Text node for subsequent chunks.
if (update.text !== '') {
reply ??= bubble(log, 'assistant');
reply.append(update.text);
reply.scrollIntoView({ block: 'end' });
examples/05-browser/src/anthropic.ts:100
- Streaming text is appended as a new text node for every chunk (
reply.append(update.text)), which can create many DOM nodes for long responses and degrade performance. Consider appending once, then mutating the existing trailing Text node for subsequent chunks.
if (update.text !== '') {
reply ??= bubble(log, 'assistant');
reply.append(update.text);
reply.scrollIntoView({ block: 'end' });
- Files reviewed: 15/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
Appending one text node per streamed chunk builds up thousands of DOM nodes on long responses. A shared streamingBubble helper now creates the bubble on the first chunk and grows one Text node via appendData, replacing the hand-rolled reply handling on all three streaming pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What this changes
Adds
examples/05-browser— a multi-page Vite + vanilla TypeScript app whose pages each run the agent loop entirely in the browser. Closes #96./) — streaming into the DOM, client-side tools that reach browser APIs (restyle the page, read the visitor's clock), and session persistence inlocalStorage./canvas.html) — an agent that paints: every drawing primitive is atool()executing against a 2D canvas context./structured.html) — a schema passed asresponseFormat; the typedresponse.valuefills the page's fields./anthropic.html) — the chat page withAnthropicChatClientswapped in; the agent, tools and streaming code are unchanged.The example's
buildscript runsvite build, so the workspace build gate keeps verifying that the runtime-agnostic packages bundle for a browser target.Parity
Checklist
pnpm checkpasses (lint, typecheck, build, test)