Merged
Conversation
Phase 1 of P059 (parent repo). Standalone TanStack Start app combining the SimplePDF editor iframe (left) with a chat sidebar (right, currently a placeholder), wired for a form switcher between IRS W-9 (default) and a Dutch form (TODO: swap URL once uploaded) to showcase multilingual support. Iframe points at headless.simplepdf.com/editor?open=<pdf>.
Switches the W-9 to cdn.simplepdf.com/simple-pdf/assets/demo/fw9.pdf and wires the Dutch form to the Belastingdienst Loonheffingen form at cdn.simplepdf.com/simple-pdf/assets/demo/loonheffingen.pdf. Both now live under a stable path we control (see client/assets/demo/ in the parent repo). Removes the TODO placeholder.
Adds src/lib/iframe_bridge.ts implementing the full 11-tool surface over raw window.postMessage with request_id correlation (load_document, go_to, select_tool, detect_fields, remove_fields, get_document_content, get_fields, set_field_value, focus_field, create_field, submit). Listens for EDITOR_READY + REQUEST_RESULT, rejects pending requests on dispose, 30s request timeout. EditorPane forwards the iframe ref; DebugPanel (?debug=1) exposes a button per tool with a last-50 call log so each tool can be exercised without the LLM. Switcher preserves debug across form changes. Phase 2 skips extending @simplepdf/react-embed-pdf — getFields, setFieldValue, focusField, createField are not yet in the public package; the Decision Log and plan risks are updated accordingly.
Adds the LLM-powered side of Form Copilot: - src/server/tools.ts: Zod schemas + system prompt; 6 client-side tools exposed (get_fields, get_document_content, set_field_value, focus_field, go_to_page, submit_download). - src/server/rate_limit.ts: in-memory token bucket (10/hr + 50/day per IP hash), getClientIp reads DO-Connecting-IP / CF-Connecting-IP / X-Forwarded-For / X-Real-IP in order. - src/routes/api/chat.ts: TanStack Start POST handler proxying to Claude Haiku 4.5 via @ai-sdk/anthropic, streams via toUIMessageStreamResponse. Structured 429 with retry-after on limit. ANTHROPIC_API_KEY read server-side only. - ChatPane (useChat + DefaultChatTransport): streaming assistant messages, stop button, markdown via react-markdown (no code highlighter), tool invocation cards, language-aware suggested prompts, onToolCall dispatches to the IframeBridge per tool. - InfoModal + "?" button next to the tagline explaining the human-in-the-loop framing and the BYOS privacy story (S3 / Azure Blob Storage / SharePoint).
- Forms expand from 2 to 4 use cases: Tax (W-9, default), Healthcare (CMS-1500), HR onboarding (Mutual NDA), State bureaucracy (Loonheffingen NL). Header's single switch-link becomes a "Use case" dropdown. - LanguagePicker: searchable select of 25 languages, keyboard navigable. Selection feeds into every /api/chat request as language_label and is spliced into the system prompt. Replaces the per-form bilingual suggested prompts (SuggestedPrompts removed). - InfoModal adds a "Saving time for everyone" section with per-use-case bullets and a PII warning: data typed to the assistant is sent to Anthropic for this demo.
- Language selection now persists as ?lang=<code>; the route's search schema validates it and the ChatPane becomes a controlled view over that URL state. Form switcher preserves lang; language picker preserves form and debug. - Prefilled prompt buttons are back (Help me fill this form / Which fields are still empty? / Explain each field in one sentence) inside SuggestedPrompts, replacing the prose "Try ..." hint in the empty state. - Languages: drop Russian, add Estonian.
Drops the generic TanStack scaffold favicon/logos and points the tab icon at https://simplepdf.com/favicon.ico so the app matches the brand. Removes unused public/{favicon.ico,logo192.png,logo512.png,manifest.json}.
Adds a small footer section to the info modal: - "Powered by the SimplePDF Pro plan" with a link to /pricing. - "Source code on GitHub" pointing at simplepdf-embed/tree/main/examples/pdf-form-copilot. - ghbtns.com GitHub star button for SimplePDF/simplepdf-embed.
Cuts token pressure against Anthropic's per-minute rate limit: - get_fields: drops 'name' when it duplicates field_id and strips empty values (W-9 saved ~50% of field metadata bytes). - get_document_content: caps each page to 900 chars, appends '… [truncated]'. - Server: maxOutputTokens=500 so the assistant can't produce walls of text per turn, maxRetries=2 for transient 429s. Also moves the Pro-plan + source-code banner to the very top of the info modal (was at the bottom), since discovery of paid plan features + OSS status is the first thing to communicate.
- /api/summarize: new TanStack Start POST handler that compresses extracted PDF text into a ~250-word summary using Claude Haiku 4.5. Shares the same in-memory IP rate limit as /api/chat. Respects the requested reply language. - Client: get_document_content tool dispatch now calls /api/summarize when total content exceeds 1500 chars, replacing raw pages with a single summary in the tool output. Falls back to per-page truncation (900 chars) if the summarize call fails. Info modal: - Move the "Heads up" amber warning above the marketing sections so the PII/tool-call disclosure is the first thing after the Pro-plan/GitHub banner. - Healthcare use case body: add "and LLM provider" to reinforce that submissions route straight to the provider's own storage AND chosen LLM, not through SimplePDF's servers.
- Info modal body becomes max-w-4xl and the "Saving time for everyone" section switches to a responsive grid (2 cols sm, 4 cols lg). Adds a fourth use case: Insurance (ACORD applications, claims, endorsements) sitting between Healthcare and State bureaucracy. - Switcher gains a fifth form: "State bureaucracy (scanned PDF)" pointing at a 130 DPI rasterized version of the Loonheffingen form (see parent repo: client/assets/demo/loonheffingen-scanned.pdf). Demonstrates field detection + OCR on forms with no native fields.
Converts /api/chat from the string-form \`system:\` param to two system messages at the head of the messages array, marking the static one with providerOptions.anthropic.cacheControl = ephemeral. Anthropic caches tools + system up to the breakpoint, so the tool schemas (~500 tokens) + base system prompt (~300 tokens) become a cache read from the second turn onwards — exactly the lever that reduces input token cost 50–90% per Anthropic's guidance. The dynamic \`Reply in <Language>\` line stays as a second, uncached system message so language changes don't invalidate the cache. Adds onFinish logging to surface input_tokens / cached_input_tokens in the server log so we can verify the cache is actually hitting.
…DAY) Local development keeps running into the 10/hr + 50/day cap while iterating. Exposes the limits via RATE_LIMIT_PER_HOUR and RATE_LIMIT_PER_DAY with safe parsing; falls back to the production defaults (10 / 50) when unset. .env.example documents the override. The limiter is still in-memory; a dev restart wipes the bucket either way.
Avoids the Ctrl+C + npm run dev dance when hammering the demo locally.
404s in production (NODE_ENV === 'production'). Usage:
curl -X POST http://localhost:3001/api/rate-limit-reset
Returns { cleared: <count> } with the number of IP buckets wiped.
…le use cases Reshapes the assistant's flow per the product spec: - System prompt now prescribes: always parallel get_fields + get_document_content first; if 0 fields, call detect_fields; if still 0, call select_tool(TEXT) and guide the user to add fields manually; if labels are opaque paths, infer meaning from get_document_content. - Re-exposes select_tool and detect_fields to the LLM (in addition to the existing 6 client-side tools) with Zod schemas. - New Toolbar below the chat header (cursor / TEXT / CHECKBOX / SIGNATURE / PICTURE). Clicking a tool drives bridge.selectTool; when the LLM calls select_tool, the toolbar mirrors it so the human and the copilot share the same state. - While a non-cursor tool is active, the chat polls bridge.getFields every 500ms; on an increase it auto-sends a "N new field(s) were just added" user message so the copilot keeps guiding the user without needing a manual trigger. - Info modal's use-case cards become clickable (Healthcare, State bureaucracy, HR onboarding each map to a form). Insurance stays as a non-clickable teaser. Footer copy updated to "Click on any of the use cases above to switch forms."
… default Two behaviour changes: 1. No technical language. The assistant never mentions tool names, field ids, paths, APIs, or "the editor". It speaks about "the form" and its fields only. Replies stay brief and polite, no filler. 2. Fill by default, ask as last resort. The assistant now calls focus_field + set_field_value on anything it can infer, and only asks the user when the field is a SIGNATURE / PICTURE (requires a human gesture) or when it genuinely lacks the data (personal details the user must provide). Flow rules (parallel get_fields + get_document_content, detect_fields fallback, manual-add via select_tool(TEXT), opaque ids resolved via get_document_content) are preserved but reworded in user-facing terms.
Two extra rules:
- No meta commentary. The assistant never narrates what it is about
to do ('I'll look at the form', 'Let me check...', 'Now I'll...').
It just does the work. It also never recaps the form layout or
lists sections up front — the user wants the form filled, not a
tour.
- One question at a time. When the assistant needs data from the
user, it asks for exactly one piece of information (tied to the
current field) and waits for the answer before moving on.
Explicit good vs. bad example in the prompt.
Latency diagnosis: - Skip the /api/summarize sub-agent by default (useSummarizer: false in dispatch). Truncate pages to 900 chars each instead. The summarize sub-agent was adding a full Anthropic round-trip (~1–2s) on every first turn; saving it buys back that latency. - Keep the summarizer wired (with in-memory cache keyed by language + document name + content length) so we can flip it back on later by flipping useSummarizer to true. Timing instrumentation: - Client: log per-tool dispatch duration, turn start, first-token latency (submitted → streaming), and total turn duration (submitted → ready). All prefixed with [copilot] so they're easy to grep. - Server: onFinish now includes elapsed_ms alongside token usage so we can compare client-perceived vs server-side time to separate network from model latency.
Two behaviour changes reported as broken:
1. The model was narrating every step ('Great! I detected 13 fields',
'Now I'll fill in your BSN'). The prompt now lists forbidden
openers explicitly ('Great!', 'Perfect!', 'Now I'll...', etc.) and
bans announcing field counts, progress, or form recap.
2. After a successful set_field_value the model was stopping instead
of moving on. The filling loop now states explicitly: IMMEDIATELY
focus_field + set_field_value (or ask one question) for the NEXT
field, no 'Done!' or 'Now I'll move on' messages in between.
These rules are strict and use clear forbidden phrases to push the
model past its default chatty prior.
…d scoping
Two fixes against observed behaviour:
1. Text between tool calls. Haiku kept narrating ('I'll check the
form for you', 'Let me pull that:'). The prompt now states: the
assistant emits text ONLY when (a) asking the user for data or
(b) confirming completion. Every other turn is tool calls only,
with NO accompanying text — including before the first tool call.
A worked example at the end shows the exact shape.
2. focus_field scoping. focus_field was being called before every
set_field_value, doubling round-trips per field. It is now
reserved for (a) SIGNATURE / PICTURE fields (user must act) and
(b) when the user explicitly says they want to type the value
themselves. For values the assistant knows, it goes straight to
set_field_value.
48s first-token observed in the wild. Root cause: maxRetries=2 means the AI SDK silently retries twice against Anthropic's 10k-TPM org cap, each retry honouring the retry-after header. The UI was frozen the whole time. - Drop maxRetries to 0 on /api/chat so a 429 surfaces immediately instead of burning 30-60s in backoff. - Add a "Thinking…" indicator in the chat while status is submitted|streaming so the user never sees a silent UI. - Render chat errors with a clearer 'Something went wrong' title and the full error message below. - useChat onError now logs the full error object to the console with a [copilot] prefix so we can grep real-world failures.
Observed: model called set_field_value({value:"true"}) on a checkbox
because the old system prompt explicitly said to. Editor rightly
rejected it, and the model carried on as if nothing happened.
Two fixes:
1. Correct checkbox protocol everywhere.
- System prompt: value="checked" ticks, value=null un-ticks. Never
"true"/"false"/"yes"/"no".
- Per-field-type rules added (TEXT, BOXED_TEXT, CHECKBOX,
SIGNATURE, PICTURE).
- Zod schema for SetFieldValueInput now permits null and its
description mirrors the same rule, so the model sees it in the
tool definition too.
2. Require the model to read tool errors and fix them.
- New "Handling tool errors" section: if a tool returns
success=false, read error.message and correct the next call. Do
not silently skip. If unrecoverable, tell the user briefly and
ask how to proceed.
Anthropic's 10k-TPM org cap was still triggering mid-conversation: the existing cache_control on the static system prompt (Breakpoint #1) only reuses the first ~1000 prefix tokens. Everything after (tool schemas dynamic language line message history) is a fresh input every turn — with 13 fields, get_fields alone carries ~400 tokens per turn and grows as tool results accumulate. Adds a second cache breakpoint on the LAST model message in the conversation (which is the current user turn in normal flow, or the most recent tool result during auto-continuation). Anthropic caches everything up to and including that block, so the next turn's prefix is a pure cache read (10% of input-token cost and, crucially, doesn't count against the non-cached TPM bucket). Two breakpoints total (system + rolling) — Anthropic allows up to four. Cache writes still happen on the incremental tail each turn (new user message + assistant reply), so expect a small cache-write overhead on each turn in exchange for a large cache-read saving thereafter.
First-turn get_document_content was blowing past Anthropic's 10k-TPM by itself when forms are 3-4+ pages. The truncation was 900 chars per page with no page cap, so worst case ~3600 chars (~900 tokens) per call plus it lives in message history every turn after. - Per-page cap drops from 900 to 400 chars. - New total-page cap of 4. If the doc has more pages, a single placeholder entry records how many were omitted. Combined with the rolling prompt cache just landed, this keeps get_document_content under ~400 tokens and that payload gets cached on turn 2+ so it stops counting against the TPM bucket.
Combined with loadingPlaceholder=true the iframe now boots in a clean state: any native AcroForm fields baked into the PDF are stripped, forcing every demo run through the same get_fields (0) → detect_fields path regardless of the form. Makes the scanned-PDF and native-PDF flows converge on the same UX.
Caps dropped from 4 pages x 400 chars to 1 page x 1200 chars. The first page is enough for the model to infer what the form is and what it asks for; extra pages mostly added tokens without changing the filling flow. A placeholder entry still records how many pages were omitted. Net effect: get_document_content's worst-case footprint drops from ~1600 chars to ~1200 chars, and more importantly it collapses into a single cached tool result on turn 2+.
Doubles the demo's per-IP quota. Rationale: - Haiku 4.5 with our prompt-cache setup lands around \$0.002-0.01 per turn, so 20/hr caps exposure at ~\$0.20/hr and 100/day at ~\$1/day per IP. - 10 unique users a day ~\$10/day ceiling, which is safe. - Existing RATE_LIMIT_PER_HOUR / RATE_LIMIT_PER_DAY env overrides still work if we need to dial further up (or down) without a code change.
Two coupled changes so the user instantly sees where input is expected: 1. System prompt now requires wrapping every user-facing question (and hand-off instructions for SIGNATURE/PICTURE fields) in Markdown bold. Updated GOOD/BAD examples use the new format. 2. ReactMarkdown in the chat pane maps <strong> to text-sky-700 + font-semibold for assistant messages (user messages keep their current white-on-blue styling).
Real cost per full form fill is ~4¢ but we were exhausting 20/hr in minutes because every tool-call auto-continuation (sendAutomaticallyWhen after addToolOutput) hits /api/chat and we counted each POST as one hit. One user turn triggers 5-10 auto-continuation POSTs. Fix: only charge the rate limit when the last UI message is a fresh user turn (role='user' with a text part). Auto-continuations (whose last message is an assistant message carrying the tool_result) bypass the bucket. Log now reports counted_against_limit so we can verify. Raised defaults to 30/hr + 150/day to reflect real user-turn volume; cost cap stays around ~\$1.50/IP/day.
Updates EDITOR_HOST, EDITOR_ORIGIN and the README reference from headless.simplepdf.com to pdf-form-copilot.simplepdf.com so the demo runs on its dedicated subdomain.
…ownload for demo)
Splits the single submit_download tool into separate submit and download
tools and gates exposure by VITE_SIMPLEPDF_COMPANY_IDENTIFIER.
- companyIdentifier === 'copilot' (the SimplePDF-hosted demo): only
`download` is registered; toolbar shows Download (Lucide Download icon);
click + LLM tool both route through the upsell-aware host handler that
ultimately fires bridge.download() (the existing demo flow, unchanged).
- companyIdentifier !== 'copilot' (any Pro fork): only `submit` is
registered; toolbar shows Submit (Lucide Send icon); click fires
bridge.submit({ downloadCopy: false }) directly (no upsell modal — a
Pro deployment is already past that point); the LLM-driven submit goes
through the same dispatcher path. The SimplePDF SUBMIT iframe event
carries the filled PDF to the host's BYOS storage + webhook stack.
Implementation:
- src/lib/mode.ts (new) exports MODE / IS_DEMO_MODE from the build-time
Vite env var. Both client and server import it; the constant is inlined
per build.
- schemas.ts: SubmitDownloadInput → SubmitInput + DownloadInput;
CLIENT_TOOL_NAMES gains 'submit' and 'download', drops 'submit_download'.
- dispatch.ts: separate cases. submit → bridge.submit({downloadCopy:false}),
download → bridge.download().
- server/tools.ts: SYSTEM_PROMPT const → buildSystemPrompt({ action })
function. The two references to submit_download in the prompt + the
"ready to submit" closing line are parameterised by the action verb /
tool name so the LLM never sees the wrong name.
- chat.ts (server route) and byok/transport.ts both pick the matching
tool entry + matching system prompt from IS_DEMO_MODE at module init.
- chat_pane.tsx: new fireSubmit callback; handleFinalisationRequested
resolves to fireSubmit (Pro) or handleDownloadRequested (demo).
createDemoDownloadMiddleware now matches `download` (was submit_download)
and is only composed in demo mode — Pro never registers the tool, so
the middleware never fires.
- toolbar.tsx: prop rename downloadPrimary/onDownload →
finalisationPrimary/onFinalisation; reads IS_DEMO_MODE to pick the
Lucide icon + label key. Demo: Download icon + toolbar.download. Pro:
Send icon + toolbar.submit.
- locales: toolbar.submit (which previously held the value "Download")
was renamed to toolbar.download in all 23 files, preserving every
existing translation. en.json gains a new toolbar.submit: "Submit"
(only EN; the 22 non-EN locales fall back to EN until the final
translator sweep, per the deferred-translator process rule).
tsc clean, vitest 50/50 passing.
…true while open
When the LanguagePicker disables on `isStreaming` (chat is streaming, including
during tool-call retries) and a tool fails, isStreaming stays true for the full
retry cycle. If the user had opened the picker before the failure, the trigger
button's HTML `disabled` attribute blocks its own onClick, leaving the panel
visually open with no way to close it via the trigger. Click-outside and
Escape technically still work but the UX reads as broken.
Auto-close matches the semantic of `disabled` ("no interactions"). When the
prop flips to true while open, the dropdown closes itself.
…isStreaming} on LanguagePicker Previous commit (8c0204c) patched the symptom — a useEffect inside the generic Dropdown that auto-closed the panel when `disabled` flipped true while open. Real cause: `disabled={isStreaming}` was passed to LanguagePicker with no product justification (defensive default since the Phase 3.36 scaffold rename). Removing it makes the dropdown always interactable and the bug disappears without touching the generic component. If the user changes language mid-stream, the URL flips, the editor reloads into the new locale, and the in-flight LLM response lands in the new thread under the new cacheKey. Slightly disorienting but recoverable, and the user explicitly initiated the change.
…sure even with a failed tool `isExpanded` was computed as `isManuallyExpanded || hasError`, which forced the disclosure open whenever any tool in the group had errored. The toggle flipped internal state but the OR clamped the visible state to expanded. Replaced the boolean-OR with a single nullable override: null until the user clicks (defaults to hasError, so errors still auto-expand on first appearance); once clicked, the user's choice wins. Trades auto-re-expand on subsequent errors for the ability to dismiss — one click to re-open if needed, and unable-to-collapse is the worse failure mode.
… 'pro' → 'simplepdf_customer' Addresses code-review findings on commit 305cb20: - New shared `finalisation.ts` exports `FINALISATION_TOOL`, `FINALISATION_ACTION`, `withFinalisationTool` and the discriminated `FinalisationToolMap` type. The helper takes `T & { submit?: never; download?: never }` so any static tool map declaring a `submit` or `download` key becomes a compile error rather than a silent overwrite from the spread. Replaces the duplicated `Record<string, ...>` typing in chat.ts + byok/transport.ts. - chat.ts and byok/transport.ts now import `FINALISATION_ACTION` from the shared module and call `withFinalisationTool({...})` instead of spreading. The static map definition is the single source of truth in each file; the finalisation tool is composed by the helper. - chat_pane.tsx imports `FINALISATION_ACTION` from the same shared module (drops the duplicate inline definition). `handleFinalisationRequested` is now memoised via `useMemo`, matching the file's pattern for handlers passed as props. Middleware composition is immutable: a `sharedMiddleware` array is the base; demo mode prepends the demo download middleware via spread instead of mutating with `unshift`. - `Mode` type literal `'pro'` renamed to `'simplepdf_customer'` to reflect what the mode actually means (any non-demo SimplePDF customer fork, not specifically the SimplePDF Pro tier). Comments updated to match. tsc clean, vitest 50/50 passing.
Splash modal shown on first visit, gated to lg+ viewports (the existing mobile fallback in Layout already takes over below 1024px). Persistent dismissal via localStorage key form-copilot:welcome-dismissed. - New WelcomeModal component overlays the brand artwork with two CTAs on the right half of the image. Get-started (Button size lg, ~2x default) dismisses the modal; "How Form Copilot works" dismisses + opens the existing info modal via ?show=info. Backdrop dismiss + Escape + close button (top-right) all trigger the same dismissal path. - Image hosted on the SimplePDF CDN via the assets-upload skill: https://cdn.simplepdf.com/simple-pdf/assets/meta/form-copilot-welcome.png Same image is now the og:image / twitter:image for the form-copilot app. og:title, og:description, og:type, og:image:width/height and twitter:card meta tags added in __root.tsx. - Button gained an lg size (rounded-lg px-6 py-4 text-base) — used by the Get-started CTA, available for any other hero-button surfaces. - localStorage gate: lazy initialiser checks dismissal + matchMedia(min-width 1024px) once on mount; SSR returns false to avoid hydration mismatch. The modal pops in on the client when both conditions are met. - Final translator sweep: 5 new keys (toolbar.submit, welcomeModal.{title, getStarted, howItWorks, close}) added across all 22 non-EN locales, plus audit confirmed the 12 mid-iteration EN keys are still aligned with their non-EN translations. Bonus fi.json typo fix (Tekoaly → Tekoäly). tsc clean. JSON parses across all 23 locale files.
…uage locale detection
Welcome modal redesigned and lifted into the SSR pass.
Visuals:
- New illustration (CDN: common/form-copilot-illustration.png) replaces the
text-baked artwork; the brand text is now translatable HTML/CSS on the
right pane.
- Two-column grid (50/50) on a #96cafc background. Image flush against the
bottom-left edge so it bleeds into the modal frame. Right pane has the
Form Copilot wordmark at text-7xl, the existing header.tagline at
text-[48px] with a max-w-[340px] wrap, Get-started button (Button size
lg, +2x default), and a How-it-works link below. White SimplePDF logo
watermarks the bottom-right of the panel at h-32 w-32. EN-only highlight
on the first three words of the tagline ("AI that helps") — non-EN
locales render the existing translated tagline without colour split.
SSR via cookie:
- Loader returns `{ demoGate, welcomeDismissed }`. New server fn
`readWelcomeDismissed` reads the `form-copilot-welcome-dismissed` cookie
via `getRequestHeader('cookie')`. Lightweight inline parser (no new dep).
- WelcomeModal rewritten without `Modal`/`createPortal` so the markup
ships in the SSR HTML; mobile gating moved to CSS (`hidden lg:flex`
on the backdrop). Inner Escape listener via useEffect.
- Dismiss writes `document.cookie = ...; max-age=31536000; SameSite=Lax;
Secure` (Secure conditional on https). No localStorage, no matchMedia.
Locale detection:
- New helpers in lib/i18n.ts: `matchSupportedLocale` (full-tag → primary
fallback), `matchLocaleFromAcceptLanguage` (parses comma-separated
Accept-Language). `readInitialLocale` now also tries `navigator.languages`
on the client when ?lang= is absent, so client init matches the server's
Accept-Language detection (no hydration mismatch).
- New server fn `readPreferredLocaleWhenLangAbsent` reads Accept-Language
via `getRequestHeader` and returns null if ?lang= was explicit (so the
user's choice always wins). beforeLoad awaits the detection and calls
`i18n.changeLanguage` if needed before render.
i18n init flash fix:
- `i18n.ts` exports `i18nReady` (the Promise from `init()`). Route's
beforeLoad `await i18nReady` before render so the very first render
doesn't run with raw key strings while init's microtask is pending.
tsc clean, vitest 50/50 passing.
… tagline alignment
- New `welcomeModal.tagline` key with `<accent>...</accent>` markup wraps
the locale-equivalent of "AI that helps". WelcomeModal renders via
`<Trans>` with `{ accent: <span className="text-blue-600" /> }`, so the
blue highlight applies in every language, not just EN.
- Translator pass added the key to all 22 non-EN locales with
locale-appropriate accent boundaries.
- Locale-aware font sizing: `COMPACT_TAGLINE_LOCALES = ['en', 'vi', 'zh']`
use 48px (their tagline visible char count is < 50). All other locales
drop to 42px so longer translations (fr/tr/fi/pt at 70-78 chars) keep
the same line count as EN. Tagline `max-w` 340 → 406 to give the larger
lines a touch more room.
- FR copy edits: "Apportez votre propre IA" → "Utilisez votre propre IA"
(4 places) + "Apportez votre propre stockage" → "Utilisez votre propre
stockage" for parallel-bullet consistency. FR header.tagline updated to
the new direct-address phrasing ("L'IA qui vous aide à remplir vos
formulaires PDF, étape par étape") matching the welcome modal.
- PL header.tagline: "Sztuczna inteligencja..." → "AI..." (was the only
locale where header.tagline used a different word for AI than the rest
of the file's UI strings).
- Tagline alignment sweep: all 23 locales now have `header.tagline`
identical to `welcomeModal.tagline` minus the `<accent>` tags. Seven
more locales (cs, de, hi, nl, tr, uk, zh) had word-order drift from
the translator's restructuring around the accent boundary; their
header.tagline values were updated to match the welcome version.
tsc clean, all 23 locale JSON files parse, accent tags balanced in every
locale.
ioredis defaults (10s connectTimeout, infinite exponential retry) caused ETIMEDOUT against an unreachable Valkey instance to pile up hung sockets, congesting the Node event loop. Once the loop got congested, DO App Platform's health check timed out, the load balancer marked the instance unhealthy, and the public URL started returning connection timeouts — even though the app process was still alive. Hardening: - connectTimeout: 3s (down from 10s default). Plenty for an in-VPC managed cache; cuts the worst-case socket pile-up by 3x. - retryStrategy: bounded at 3 attempts with quadratic backoff (200ms / 800ms / 1.8s) capped at 5s, then null (give up). After give-up the limiter stays unready, callers see system_failure, and the event loop isn't dragged by new socket attempts. ETIMEDOUT during firewall-change propagation is the canonical case this guards against. When `REDIS_URL` is unset, nothing changes (in-memory fallback path). tsc clean, vitest 50/50 passing.
…App side" gotcha Operator hit ETIMEDOUT after adding the App as a Trusted Source on the Valkey cluster. The actual fix is to open the App → Settings → App Spec → "Create or Attach Database" and pick the cluster from there. DO then auto-handles trusted sources, VPC routing, and connection-string injection. Same shape as adding a custom domain — has to be done from the App side, not the resource side. Adding a callout to the README so a forker doesn't hit the same trap on their first deploy.
…at banner When DO App Platform's load balancer can't reach the App container (Valkey timeout cascade, health-check failure, deploy in progress, etc.), it returns its own HTML 503 page directly to the browser. The AI SDK builds an Error from the response body, so error.message becomes raw HTML. classifyError didn't recognise the shape (not a JSON envelope, no status property), so the error fell through to GenericPanel which rendered the HTML payload inside a <pre><code> block — visible to every visitor as ~30 lines of HTML noise. Fix: - New classifier helper isUpstreamHtmlError(message) detects upstream HTML pages by signature (DOCTYPE / <html prefix, via_upstream text, "App Platform failed to forward" string). - New error kind 'service_unavailable' takes precedence over the JSON envelope and direct-status checks. - New ServiceUnavailablePanel renders just "Something went wrong" + a clean "The service is temporarily unavailable. Please try again in a few minutes." message — no <pre> block, no raw payload exposure. - GenericPanel: "Full error" disclosure label now inherits the parent's rose-700 text colour (matching the "Something went wrong" header) instead of the lighter rose-600. Three new test cases cover the DO 503 HTML body, bare HTML wrappers, and the via_upstream signature without HTML. Total 53 tests passing (was 50). en.json gains chat.errorServiceUnavailableBody. Non-EN locales fall back to EN until the next translator sweep — flagging because this surface was added outside the welcome-modal copy iteration window.
Code-review finding on commit 6d4202a: returning `null` from ioredis's retryStrategy after 3 failed attempts permanently disabled the client (`status: 'end'`). Once the firewall propagation / DO incident / cold start exceeded the 4-attempt window, the limiter stayed unready until an app redeploy — defeating the original intent of recovery. Replaced with a quadratic backoff capped at 30s, never returning null: retryStrategy: (times) => Math.min(times * times * 200, 30_000) Worst-case event-loop cost during a long outage is two 3s connectTimeout hung sockets per minute — predictable and negligible. Once the network heals, the limiter self-recovers without operator intervention.
…able banner When the demo's chat path is down (DO 503, etc.), the user has a working escape hatch: bring their own AI. BYOK requests bypass our server entirely (browser → provider direct), so a DO outage is recoverable without waiting for it to clear. Updated `chat.errorServiceUnavailableBody` to embed a `<switchModel>` slot — same Trans-component pattern AuthPanel uses. Clicking the link opens the model picker. Wired the `onSwitchModel` callback through to ServiceUnavailablePanel.
Operator confirmed that attaching DO Managed Caching via App Platform's
"Create or Attach Database" flow auto-injects the connection string as
DATABASE_URL (not REDIS_URL). The form-copilot server reads REDIS_URL,
so the post-attach step is to either rename the auto-injected var or
add a REDIS_URL=${cluster-name.DATABASE_URL} alias.
README's DO App Platform gotcha block now spells this out so a forker
doesn't repeat the dig.
…l name in subtext
Header semantics changed per operator spec:
- Title: ALWAYS "Form Copilot" (was: model name when active, brand
fallback otherwise — confusing because the H2 styled like a model
name made the brand title look like a model in some states).
- Subtext: three-way switch.
- BYOK active + ready: model name (e.g. "Claude Haiku 4.5"),
clickable to swap providers.
- Demo mode + ready: "Use your own AI", clickable to open the model
picker. Surfaces the BYOK escape hatch on the same line where the
BYOK model name lands.
- Otherwise: status message ("Load a document first", "Bring your
own AI to start chatting", "Waiting for the editor to load…").
Refactor:
- New ChatPaneHeader component (`src/components/chat_pane_header.tsx`)
takes 10 props (byokConfig, byokModelLabel, hasActiveModel, isReady,
chatStatusMessage, isStreaming, onOpenModelPicker, onStop, language,
onLanguageChange). Owns the title + subtext + LanguagePicker + Stop
button.
- chat_pane.tsx replaces ~45 lines of inline header JSX with a single
<ChatPaneHeader /> call. Drops the now-unused activeModelLabel,
demoModelLabel, and DEMO_MODELS import.
Locale changes:
- New `chat.useYourOwnAI` = "Use your own AI" (EN only; non-EN
fall back to EN until the next translator sweep).
- Removed `chat.switchModel` ("Switch AI model") from all 23 locales —
no longer used anywhere. The `<switchModel>` Trans-tag in
`errorAuthBody` is unrelated (a slot for a button inline in the
body) and stays.
tsc clean, vitest 53/53 passing.
…PaneHeader Code-review finding on ba74fe8: ChatPaneHeader received both byokConfig and byokModelLabel, but the only use of byokConfig was a null check that collapsed to byokModelLabel !== null (the label is null exactly when byokConfig is null per the chat_pane.tsx derivation). Dropped the byokConfig prop, simplified the conditional, removed the now-unused ByokConfig type import. 9 props down from 10.
The chat-bubble rendering was a single MessageView component that branched on message.role for layout, colors, content, and strong-text accent. Operator wants the two surfaces to diverge cleanly going forward, so they're now separate components in their own files. - UserChatMessage (`src/components/user_chat_message.tsx`): right-aligned sky-600 bubble. Text-only — filters out anything that isn't a text part (user messages don't carry tool invocations by construction). - LLMChatMessage (`src/components/llm_chat_message.tsx`): left-aligned slate-100 bubble. Owns the toBlocks / RenderBlock logic and renders text + adjacent tool invocations as a single ToolInvocationGroup. Sky-700 accent on `<strong>` text. Extra pr-5 so text doesn't crowd the rounded edge. chat_pane.tsx: - Drops MessageView, MessageViewProps, RenderBlock, toBlocks. - Drops the ReactMarkdown import (no longer used at this level). - Drops the ToolInvocationGroup / ToolInvocationPart imports (moved into LLMChatMessage). - The messages.map dispatch picks the right component based on message.role, with the existing FieldAddedHint pre-check kept intact. The two new files plus a 4-line dispatch are net-positive on maintainability — diverging behaviour can land in one component without leaking branches into the other. tsc clean, vitest 53/53.
… under components/chat/ Three CLAUDE.md compliance fixes from the previous code review, plus the operator-requested file move + rename pass. Type fixes: - llm_chat_message.tsx: dropped the `as` cast on tool parts; uses the AI SDK's `isToolUIPart`, `isTextUIPart`, and `getToolName` guards. Adds a `toRenderableState` mapper that handles the SDK's wider state union (the SDK ships approval-requested / approval-responded / output-denied on top of the four states ToolInvocationGroup renders today). The cast was actually hiding this — Form Copilot doesn't use approvals, so the states never arrive at runtime, but the type now reflects reality with an exhaustive `satisfies never` default. - user_chat_message.tsx: replaces the hand-rolled type predicate with the SDK's `isTextUIPart` guard. - chat_pane.tsx: role dispatch is now a `switch` with `satisfies never` on `default`, matching CLAUDE.md's requirement for branching on literal-typed values. Adds an explicit `case 'system': return null` so a future SDK widening surfaces as a compile error rather than a silent assistant-styled render. File moves + renames (operator request): - src/components/chat_pane.tsx → chat/chat_pane.tsx - src/components/chat_pane_header.tsx → chat/chat_pane_header.tsx - src/components/user_chat_message.tsx → chat/chat_user_message.tsx - src/components/llm_chat_message.tsx → chat/chat_llm_message.tsx - src/components/hooks/use_detect_user_added_field.ts → chat/hooks/... - Component exports renamed: UserChatMessage → ChatUserMessage, LLMChatMessage → ChatLLMMessage. The `Chat<role>` prefix groups them visually in tooling lists. All relative imports updated. The empty src/components/hooks/ folder was removed. tsc clean, vitest 53/53.
…-guard edits Follow-up to 2ca537c which captured only the pure-rename half.
… easter eggs Group all chat-only components (8) under src/components/chat/ alongside the previously-moved chat_pane / chat_pane_header / chat_user_message / chat_llm_message / hooks. The chat surface is now self-contained; nothing under chat/ is referenced from outside chat/ except chat_pane. Moved into chat/: - tool_invocation_group.tsx - tool_invocation_card.tsx - tool_icons.tsx - language_picker.tsx - model_picker_modal.tsx - suggested_prompts.tsx - thinking_indicator.tsx - toolbar.tsx Moved into easter-eggs/: - cerfa_dor_modal.tsx (the FR-only Cerfa d'Or easter egg; keeping it visually separated from the rest of the surface so a reader doesn't spend time wondering whether it's load-bearing). Imports updated: - chat_pane.tsx, chat_pane_header.tsx, chat_llm_message.tsx now use ./tool_invocation_group, ./language_picker, etc. (siblings). - toolbar / language_picker / model_picker_modal moved their ../lib/... → ../../lib/..., ./ui/... → ../ui/... per the new depth. - layout.tsx imports CerfaDorModal from ./easter-eggs/cerfa_dor_modal. Final src/components/ tree: - chat/ (chat-only helpers, components, hooks) - easter-eggs/ (cerfa_dor_modal) - ui/ (Modal, Button, Dropdown primitives — unchanged) - 7 top-level files: download_modal, editor_pane, error_banner, form_picker, info_modal, layout, social_share, welcome_modal. tsc clean, vitest 53/53.
…ponents/demo/ Moved the four demo-flavoured components (the in-app surfaces that are specific to the SimplePDF-hosted demo and don't ship with a customer fork's basic loop) under src/components/demo/: - social_share.tsx — share-link buttons for the rate-limit nudge - welcome_modal.tsx — first-load splash gated on the welcome cookie - info_modal.tsx — "What is this demo?" + architecture diagram - download_modal.tsx — Download CTA with the Pro upsell card Imports updated: - layout.tsx → ./demo/info_modal - error_banner.tsx → ./demo/social_share - chat_pane.tsx → ../demo/download_modal - routes/index.tsx → ../components/demo/welcome_modal - moved files: ./ui/... → ../ui/..., ../lib/... → ../../lib/... Final src/components/ tree: - chat/ (chat surface — pane, header, messages, model picker, hooks) - demo/ (demo-specific UI: welcome / info / download / social share) - easter-eggs/ (cerfa_dor_modal) - ui/ (Modal, Button, Dropdown, TextInput primitives) - 4 top-level files: editor_pane, error_banner, form_picker, layout tsc clean, vitest 53/53.
…extract gates + update fork-and-go skill
Three coordinated changes that make the "demo vs core app" boundary
mechanical, so a customer-fork can strip the demo surface with folder
deletes instead of file-level archaeology.
Phase A — file moves:
- src/lib/{demo_model,forms}.ts → src/lib/demo/
- src/server/{shared_keys,share_query,misbehavior}.ts → src/server/demo/
- src/server/shared_keys.test.ts → src/server/demo/
Imports updated in: language_model.ts, form_picker.tsx, layout.tsx,
demo/info_modal.tsx, chat/model_picker_modal.tsx, routes/index.tsx,
routes/api/{chat,summarize}.ts.
Phase B — gate extractions:
- New `src/server/demo/gate.ts` exports `applyDemoPreflight(request)`,
bundling the duplicated preflight block (ipHash + misbehavior +
same-origin + share resolution + apiKey lookup) that was inlined in
/api/chat and /api/summarize. Both routes now reduce to:
const preflight = await applyDemoPreflight(request)
if (preflight.kind === 'response') return preflight.response
const { ipHash, resolution } = preflight
- New `src/server/demo/loader_helpers.ts` exports `DemoGate`,
`readDemoGate`, `readWelcomeDismissed`, `WELCOME_DISMISSED_COOKIE`.
`routes/index.tsx` re-exports `DemoGate` so existing consumers
(chat_pane's prop) don't have to re-route their import. The cookie
constant is exported so the dismissWelcome handler in the route
stays in sync with the read side.
Phase C — fork-and-go skill update:
- Q6 ("Customize") now offers `Strip the demo` as the trim option
(was: vague "Minimal: drop demo bits"). Description names every
surface that disappears so the user can decide concretely.
- Step 6 rewritten as 8 mechanical sub-steps (6a-6h):
- 6a: a single `rm -rf` over the four demo trees
- 6b: replace src/lib/forms.ts with a single-form catalogue
- 6c: replace `applyDemoPreflight` with a static env-driven resolution
- 6d: collapse `routes/index.tsx`'s loader to a no-demo shape
- 6e-6f: delete InfoModal/CerfaDorModal/SocialShare/RateLimitPanel
references in layout.tsx + error_banner.tsx
- 6g: strip demo-flavoured locale keys (with explicit keypath list)
- 6h: tsc + smoke test
Final demo-isolation property: nothing under `src/components/demo/`,
`src/components/easter-eggs/`, `src/lib/demo/`, or `src/server/demo/` is
referenced from outside the four demo trees. A customer-fork that
deletes those folders has 5 import-edit sites to clean up
(chat.ts, summarize.ts, index.tsx, layout.tsx, error_banner.tsx)
instead of grepping for stale demo logic across the codebase.
tsc clean, vitest 53/53 passing.
Three CLAUDE.md compliance fixes from the previous review:
- gate.ts: convert the implicit 'shared'-case fall-through to a switch
with `satisfies never` on default. A future kind added to
SharedKeyResolution now surfaces as a compile error rather than
silently routing to the 'allowed' arm with stale narrowing.
- loader_helpers.ts: drop the `as { shareId: unknown }` cast in the
inputValidator. The `'shareId' in raw` guard already narrows raw to
a record with that property; explicit `unknown` annotation on the
read carries the value forward without a cast.
- fork-and-go/SKILL.md step 6h: replace the bogus
`yarn --cwd form-copilot test:types` validation command (no such
script in form-copilot/package.json) with the actual `npx tsc
--noEmit` invocation. Added a "From inside the form-copilot/
directory:" preamble so the cwd is unambiguous.
tsc clean, vitest 53/53.
…erErrorBody envelope Two related cleanups: 1. Drop the unused `export` from `DemoPreflightResult` in `server/demo/gate.ts`. Per CLAUDE.md "NEVER export types or functions unless they have an existing consumer" — nothing outside the module referenced it. 2. Add `src/lib/api_envelope.ts` exporting a discriminated `ServerErrorBody` union. Both producers (gate.ts, chat.ts, summarize.ts) tag their `Response.json(body, ...)` bodies with `satisfies ServerErrorBody`, and the client classifier (`error-classifier/classifier.ts`) parses incoming error messages against the union via a `SERVER_ERROR_TO_STATUS` map enforced `as const satisfies Record<ServerErrorBody['error'], number>`. Net effect: a typo in an `error` token on the server fails the build, adding a new error kind forces both producer + classifier to update, and the client's recognition of unknown-status payloads is now driven by a single source of truth instead of three duplicated string-equality checks. The classifier also now recognises `forbidden_blocked`, `forbidden_origin`, and `service_unavailable` envelopes (it didn't before — it only matched on the three errors that had explicit branches). All three currently classify into `null` / `'server'` per the existing status-code rules, so behavior is unchanged; the coverage just becomes exhaustive. tsc clean, vitest 53/53.
…verErrorBody coverage - ServerErrorBody now covers bad_request, payload_too_large, unsupported_media_type so route handlers can `Response.json(body.body satisfies ServerErrorBody, ...)` end to end. SERVER_ERROR_TO_STATUS extended to keep the satisfies coverage check honest. - BodyReadFailure carries a typed `body: Extract<ServerErrorBody, ...>` field instead of loose `error: string`; routes pass it straight through. - bodyFn typed as `Omit<ChatRequest, 'messages'>` so a server-side rename of `language_label` breaks compile in the client transport. - ShowParam derived from a tuple so the type, runtime check, and URL contract stay in lockstep. - Welcome cookie write extracted to loader_helpers next to the read side; cookie name no longer exported. - Removed `as` casts: `e as Error` (shared_keys), `event.target as Node` (dropdown), `(input as Request).url` + outer `as typeof fetch` (chat_pane fetch override; the latter also fixes a latent bug where URL inputs would have read undefined), `PLACEMENT_TOOLS as readonly string[]`. - bridge.ts:152 cast deferred — load-bearing at the unknown→TData boundary; removing requires per-tool Zod output schemas (P060-scale).
… prompt (Phase 3.47) Per-(provider, model) credential vault encrypted with a non-extractable WebCrypto key in IndexedDB. Each credential carries its own optional custom system-prompt instructions (append or replace mode). The picker auto-opens to the active credential, pre-fills saved keys + instructions when the user switches to a previously-used (provider, model), and the "Forget configuration" button targets the displayed credential rather than vault.active. Apply runs the API key probe on a NEW credential and skips it when only customInstructions changed against the active one (with full identity match via credentialKey, not just apiKey, to avoid silently activating a stale model that happens to share a key string). `canSend` is held during the IDB read window so a chat dispatched mid-load cannot route to the demo path with the canonical default prompt when the user has BYOK saved. Diagnostics added for the wire (gated on VITE_ENABLE_DEVTOOLS): byok_vault.loaded, byok_vault.credential_saved, byok_vault.schema_mismatch, byok.system_prompt_built.
…l on demo Phase 3.47 unified `byokModelLabel` (BYOK-only) and `activeModelLabel` (BYOK or demo) for the header. The ErrorBanner's `resumeModelLabel` prop kept reading the unified value, so on a demo rate-limit the demo model's label was flowing in and the banner mistook it for a freshly wired BYOK credential. The user saw "You're now using <Model>" instead of "You've reached the demo limit". Force null at the call site when no BYOK is active so RateLimitPanel renders for the demo path, ResumePanel only for actual BYOK switches.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
This PR introduces Form Copilot as a MIT-licensed forkable demo. The demo runs live at https://form-copilot.simplepdf.com.
Form Copilot allows filling PDF forms using LLMs by leveraging client-side tool calling over an iframe bridge (introduced as part of this PR).
Changes
form-copilot/, built with TanStack Start (React 19, Vite, Nitro), Tailwind CSS, and the Vercel AI SDKform-copilot/src/lib/embed-bridge/together with two adapters:embed-bridge-adapters/react(host-sideuseEmbedBridgehook) andembed-bridge-adapters/client-tools(LLM-tool input/output Zod schemas)Notes
We will most likely move the iframe bridge into the web-embed package since we've so far shipped poor-man's bridges per consumer (less capable, less durable, less typed than what a shared package would offer).