Skip to content

feat(mini-apps): embed remote apps with a context-aware chat (PoC) - #1229

Open
darkbanjo wants to merge 89 commits into
mainfrom
jkab/mini-apps-poc
Open

feat(mini-apps): embed remote apps with a context-aware chat (PoC)#1229
darkbanjo wants to merge 89 commits into
mainfrom
jkab/mini-apps-poc

Conversation

@darkbanjo

@darkbanjo darkbanjo commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

What this is

A proof of concept for Mini Apps — a first-class surface for embedding a remotely-hosted web app in
Thunderbolt, with a bidirectional bridge so a chat beside it can read and act on what the user is
looking at.

The motivation is enterprise customization. Prospects each want their own features; some belong in core,
many are one-offs that would otherwise turn into per-customer branching in this codebase. This gives
those asks somewhere to live. The goal we designed against: onboarding a customer app should be a
registry entry, not a code change.

Draft deliberately — this is up for discussion on the protocol design before anyone reviews line by
line. It's behind experimental_feature_mini_apps, off by default.

Try it

The sample app lives outside this repo at ~/code/sample_finance_app (Next.js, port 5174) — a
standalone quarterly P&L model that happens to speak the bridge. Ask me for it and I'll share it.

  1. Enable Settings → Preferences → Preview Features → Mini Apps
  2. Click Finance Model in the sidebar
  3. Ask: "Q4 operating margin is under 10%. What growth rate would we need to get it above 20%?"

The model calls a tool to change an assumption, reads the recomputed projection back, and iterates until
it converges (~20.5%). The table recalculates on screen while it works.

Design decisions worth arguing with

1. iframe, not the Tauri native webview. src/content-view/sidebar-webview.tsx exists for arbitrary
third-party sites that send X-Frame-Options. A mini app is cooperative — the customer sets
frame-ancestors — so an iframe works on web and desktop and gives us postMessage for free.
Bridging a native webview would be harder and would strand the feature on desktop.

2. JSON-RPC 2.0 over postMessage, modeled on ACP's handshake. We already speak JSON-RPC for agents
(backend/src/haystack/acp-server.ts), so this is one wire idiom rather than two. The capability
negotiation — not the method list — is the part built to last: the method set is deliberately tiny, and
new capabilities are additive for apps that don't declare them.

3. Live app state reaches the model through a tool, not prompt injection. This follows a decision
already documented in src/projects/project-search-tool.ts: injecting volatile state would invalidate
the cacheable stable prompt on every interaction. Only the app's identity goes in the prompt section;
its state is read via get_app_context.

4. The host owns all the chrome. The Chat button, the highlight-to-ask popover, the marquee dim
overlay and drag box, the approval prompt — all rendered by Thunderbolt. The app contributes one
hit-test function and a context payload, and cannot style any of it wrong. Measured integration cost in
the sample app: three mandatory touchpoints, ~30 lines, no Thunderbolt dependency in its
package.json.

5. Tool descriptors are WebMCP-shaped, but we don't depend on WebMCP. document.modelContext covers
exactly this topology (cross-origin iframe, allow="tools" + exposedTo), and we'd rather use it. We
can't yet: Tauri embeds WKWebView on macOS and WebKitGTK on Linux and neither implements it; Chrome's
support is a time-boxed origin trial (149→156) of a W3C Community Group draft that has already renamed
its entry point; and Mozilla's standards position is neutral with Safari uncommitted. So the descriptor
mirrors WebMCP field for field — an app already using registerTool hands us the same objects, and if
WebMCP ships broadly this becomes an adapter rather than a migration. Method names are MCP's own
(tools/list, tools/call). Full reasoning is in the tools section of shared/mini-app-protocol.ts.

Security posture

Every inbound message is gated on source window and origin independently — origin alone would trust
a different frame on the same host, source alone would keep trusting our frame after it navigated away —
then parsed with zod. postMessage is never called with '*' in either direction.

Write tools require approval, enforced host-side from the descriptor. An app that lies about
readOnlyHint can only ever cause an extra prompt, never skip one, and an absent annotation means
"ask".

One subtlety flagged in a comment so nobody "fixes" it: the iframe uses allow-scripts with
allow-same-origin, which src/artifacts/verify-html.ts warns against. That warning is correct for
srcdoc content, which would inherit Thunderbolt's origin. A cross-origin app keeps its own, so the
pairing is both standard and necessary there.

Things reviewers should push on

  • get_app_context is scoped to "an app route is mounted", not per chat thread. Safe today because
    the embedded chat is the only reachable chat on that route, but it's the first thing I'd tighten.
  • ACP agents don't get app tools. chat-instance.ts has a separate prompt path that deliberately
    doesn't advertise AI-SDK tools (same reason search_project_chats is excluded there). Fine for a PoC,
    a real gap if a customer wants their own agent alongside their own app.
  • The registry is static. Per-account provisioning is out of scope here.
  • Two shared surfaces changed. ChatHydrateHandler is now exported so a non-route surface can host
    a real chat session, and it gained navigateOnCreate so an embedded chat's first send doesn't
    navigate away and unmount its host. Both are additive and default to current behaviour, but they're
    the changes most likely to affect other work.
  • Highlight-to-ask can change host-app behaviour. Dragging to select over a clickable element fires
    its onClick on mouseup; the sample app needed a guard. watchSelection: false opts out.

Deploying this

mini_app_id is a new column on chat_threads, not a new table, so the two-PR flow doesn't apply:
the sync rule is already SELECT * FROM powersync.chat_threads and the client schema is Drizzle-derived,
so nothing in the sync-rule configs changes. Same shape as devices.node_id before it.

The ordering still matters, though, and the failure is quiet:

  1. Merge, then run migration 0029_jazzy_cardiac on prod Postgres.
  2. Let the PowerSync service re-replicate chat_threads before relying on the column cross-device.

A frontend running ahead of the migration does not stall the CRUD queue — toSchemaRecord
(backend/src/dal/powersync.ts:75) skips unknown columns rather than rejecting the op. It silently
drops mini_app_id, so chats created in that window lose their app link until the backend catches up.
Self-correcting, but invisible while it's happening, which is why it's worth doing in order.

Testing

bun run test — 4552 pass. New coverage for protocol validation (including origin/source/version
rejection), marquee geometry, selection placement, tool approval semantics, prompt sections, and a
regression test for the embedded-chat navigation bug. make check clean.

A first-class surface for enterprise customization: a remotely-hosted app
embedded as a route, with a bridge so a chat beside it can read and act on
what the user is looking at. Onboarding a customer app should be a registry
entry, not a code change.

- Protocol in shared/mini-app-protocol.ts — JSON-RPC 2.0 over postMessage,
  with an ACP-style capability handshake. The negotiation is the part built
  to last; the method set is deliberately small
- Host in src/mini-apps/ — registry, bridge, route page splitting 2/3 app to
  1/3 chat, and origin+source validation on every inbound message
- Live app state reaches the model through a get_app_context tool rather
  than prompt injection, so a changing view doesn't invalidate the cacheable
  stable prompt on every interaction
- Three ways to reach the assistant: a floating Chat button, highlight-to-ask
  over selected text, and a host-drawn marquee that snaps to whole elements.
  All three attach passages via the existing pending-quotes composer chips
- Apps can declare tools. Descriptors are WebMCP-shaped so an app already
  using document.modelContext ports unchanged, but we don't depend on it —
  Tauri's WKWebView/WebKitGTK can't run it, it's a time-boxed origin trial
  of a non-standards-track draft, and Mozilla is neutral on it. Method names
  are MCP's own (tools/list, tools/call)
- Write tools require approval, enforced host-side from the descriptor: an
  app that lies about readOnlyHint can cause an extra prompt, never skip one

Behind experimental_feature_mini_apps, off by default. Sample app lives
outside this repo at ~/code/sample_finance_app.

Two shared-surface changes were needed: ChatHydrateHandler is exported so a
non-route surface can host a real chat session, and gains navigateOnCreate
so an embedded chat's first send doesn't navigate away and unmount its host.
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Semgrep Security Scan

Found 2 issue(s).

# Severity Rule File Line
1 WARNING wildcard-postmessage-configuration e2e/artifact-harness.spec.ts L68
2 WARNING wildcard-postmessage-configuration src/components/artifact/sandboxed-html-frame.tsx L158
Finding details

wildcard-postmessage-configuration — e2e/artifact-harness.spec.ts:68

Severity: WARNING
Message: The target origin of the window.postMessage() API is set to "*". This could allow for information disclosure due to the possibility of any origin allowed to receive the message.

requires login

wildcard-postmessage-configuration — src/components/artifact/sandboxed-html-frame.tsx:158

Severity: WARNING
Message: The target origin of the window.postMessage() API is set to "*". This could allow for information disclosure due to the possibility of any origin allowed to receive the message.

requires login

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Preview environment deployed 🚀

Service URL
Marketing / blog / docs https://thunderbolt-pr-1229.preview.thunderbolt.io
App https://app-pr-1229.preview.thunderbolt.io
API https://api-pr-1229.preview.thunderbolt.io
Keycloak https://auth-pr-1229.preview.thunderbolt.io
PowerSync https://powersync-pr-1229.preview.thunderbolt.io

Stack: preview-pr-1229 · Commit: b2b71f920063c68586e9579fc1a17ab08d4ce66d

Auto-destroys on PR close/merge. Login via the bundled Keycloak realm — demo@thunderbolt.io / demo by default.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

PR Metrics

Metric Value
Lines changed (prod code) +7759 / -212
JS bundle size (gzipped) 🟢 640.0 KB → 646.8 KB (+6.8 KB, +1.1%)
Test coverage 🟡 82.15% → 80.49% (-1.7%)
Performance (preview) Preview not ready — Render deploy may have timed out
Accessibility
Best Practices
SEO

Updated Sat, 05 Sep 2026 02:12:29 GMT · run #2957

Second entry in the registry, which is the point of the registry: onboarding
a customer app is a config line, not a code change.

The app itself lives outside this repo at ~/code/patient_journeys_app — a
dashboard of diseases where each card opens a patient journey (a pre-launch
commercial tool: symptoms, diagnosis, therapy start, follow-up, with a source
and a confidence tier behind every figure).

Port 5180 rather than the next number up from the finance sample: `next dev`
silently walks forward when its port is taken, so two apps on adjacent ports
can quietly swap places while the registry still points at the old one.
MCP Apps landed in January as the first official MCP extension, and its
wire shape is ours almost line for line: JSON-RPC over postMessage, a
sandboxed frame, UI-initiated tool calls routed through the host's consent
path. Where the semantics genuinely match, use their names — ui/initialize,
ui/update-model-context, and MCP's own tools/* which we already had.

We are not adopting MCP Apps itself. It delivers UI as an HTML string over
a ui:// resource rendered inside a sandbox proxy, so the app ends up with
no origin of its own: no cookies, no same-origin calls to its own backend,
nowhere for an OIDC redirect to land, and a lifecycle attached to a single
tool result rather than an application the user navigates. A Mini App is a
cooperative app deployed at a real URL and keeps all of that.

Where they have no equivalent we stay in the namespace and pick our own
name rather than bending theirs: ui/open-chat (their ui/message sends a
message; ours opens the panel and seeds the composer) and the selection
pair, which has no counterpart.

Breaking, hence protocolVersion 2 — but it costs a find-and-replace today,
with two guest apps that are both ours and nothing shipped. After the
template repo exists it becomes a coordinated migration.
A theme-only notification was already the wrong shape the first time a
second property came along. Group the host's ambient state into one
MiniAppHostContext — theme, locale, platform — handed over whole at
ui/initialize and re-sent as a patch whenever part of it moves.

locale is the property that pays for the change immediately. Patient
Journeys ships an EN/DE toggle and had no way to know which the user
wanted, so it defaulted to English for a German audience; it now follows
the host and only stops once the user picks for themselves. Sourced from
navigator.language for now — it should move to the account's language
setting when the i18n layer lands (THU-812).

platform comes from the existing helpers in lib/platform rather than
sniffing again, so an app and its host can't disagree about where they
are. Mostly groundwork for THU-830: an app that knows it's on a phone can
drop to one column instead of inferring it from a viewport width.

Partial updates rather than whole-object replacement so adding a field
later doesn't force every guest to re-read state it didn't ask about.
An embedded app should integrate with one issuer — us — rather than with
every customer's IdP. However the user signed in (magic link, enterprise
OIDC), the app gets the same short-lived JWT and validates it the same
way. Without this, "onboard a customer app with a registry entry" quietly
becomes "integrate that customer's identity provider".

POST /mini-apps/:appId/token mints it. Three deliberate choices:

- The audience is operator-declared, from MINI_APP_AUDIENCES on the
  backend, never from the caller. A client that names its own `aud` can
  mint a token any app would take.
- Secrets are per app. One shared symmetric key would let any mini app
  forge a token for any other. Asymmetric keys and a JWKS endpoint are
  the upgrade once apps are third-party-built; this is the right size
  while we deploy both halves.
- We never hand over the user's Thunderbolt session, whose audience is
  us. That would be audience confusion, and a good app rejects it anyway.

The backend owns only the security-relevant half of the registry —
appId to origin and secret. Presentation stays in the frontend registry,
because signing doesn't need an icon.

Guests declare an `auth` capability, so an app that never asked doesn't
cause a credential to exist, and get `getAuthToken()` rather than a raw
token: it refreshes 30s before expiry, since a frame can sit open for
hours and an expired token fails at the far end where the error is least
legible. Refresh is guest-initiated — only the app knows it still cares.

Also fixes two things found on the way: the guest binding rebuilt its
callbacks every render, so the context-publishing effect re-fired and
re-posted an identical message each time; and the bridge effect read
cloudUrl without depending on it, which would mint against a stale
backend URL once settings load a beat after first render.
The registry was a hardcoded array with localhost URLs, so pointing
Thunderbolt at a customer's app meant editing source and cutting a build
per customer. Now it lives in one backend env var (MINI_APPS) and the
frontend reads it over GET /mini-apps with secrets stripped.

One config rather than two. The obvious smaller change was a VITE_ var
for the frontend list, leaving MINI_APP_AUDIENCES on the backend for
signing — but that is two lists of the same apps that can disagree, and
the failure mode is silent: an app the backend doesn't know about renders
in the sidebar, loads fine, and then can't authenticate. Folding
presentation into the same entry makes that unrepresentable.

`origin` stays separate from `url` rather than derived, for the reason
already in the type: a redirect can move `url`, and the value we validate
inbound messages against has to be the one an operator declared. `url`
defaults to `origin` so nobody has to write it twice.

Icons are an allowlist keyed by name — config is operator-supplied, and
turning an arbitrary string into a component import is a bigger surface
than picking an icon warrants. Unknown keys fall back rather than crash.

The route now waits on the fetch before deciding an app doesn't exist;
redirecting on a still-loading registry would bounce valid deep links to
Not Found on any cold load.

Tauri's frame-src is still compiled in, so desktop remains per-build
until THU-830 deals with it.
The 2/3 app to 1/3 chat split has nowhere to go on a phone. On mobile the
chat now covers the app instead, with its own header and close button.

The app frame stays mounted underneath rather than being swapped out.
That matters more than it looks: unmounting it would tear down the bridge
and drop the very context the user opened the chat to ask about.

One chat pane, hoisted and placed in one of two positions, so the two
layouts can't drift — and because two copies of ChatHydrateHandler would
be two sessions.

Marquee select needed no change; it already speaks pointer events, so
touch works.

Not visually verified: the route is behind the Mini Apps preview flag and
needs a signed-in account, which this dev browser doesn't have.
Three of these have now been re-derived from scratch in conversation more
than once, which is the signal they should have been written down:

- Why not MCP Apps, whose wire shape is nearly ours. Its delivery model
  leaves the app with no origin, and the identity design depends on
  having one. External URL support is deferred, not rejected — that's
  the trigger to revisit, rather than a general pull toward the standard.
- Why not WebMCP, which has no UI transport at all and whose natural home
  here is the native webview, on the one surface (Tauri/WebKit) that
  can't run it.
- One embed path for every surface, because Mini Apps are cooperative.
  Includes the Tauri frame-src problem, which is genuinely unsolved:
  everything else moved to runtime config, but the desktop CSP is
  compiled in, so desktop stays per-customer-build while web does not.

Also the security posture, since it comes up on every deployment
conversation: network isolation is the perimeter, app auth is the
enforcement, and the iframe relationship is not itself a control.
Two rough edges on the chat surface.

**Approval prompt.** It read like a debug panel: tool identifier in the
headline, the model-facing description verbatim underneath, and raw JSON
below that. Now it leads with `annotations.title` — the sentence the app
author wrote for this exact moment — and renders arguments as pairs,
because the arguments *are* the decision. The description is aimed at the
model (long, full of instructions about when to call the tool) so it
moves behind a disclosure along with the JSON, next to who asked.

Native <details> rather than a Collapsible primitive: no shared state,
and the element already handles keyboard and screen readers.

**Selection import.** `data` — the structured payload an app can return
from resolveSelection — was being dropped entirely, so an app that handed
over domain objects got the same result as one that let the default
hit-test scrape text. That made the better path pointless, which is the
same as not having it. It now rides along with the text, capped, and
tolerant of the cycles structuredClone can carry.

Selecting a dozen rows also produced a dozen chips the user had to read
past to find their own prompt; past three they collapse into one passage,
which is more honest anyway — it was one gesture, not a dozen decisions.

Writing the render tests turned up the tool name appearing twice when no
title is declared, once as the heading and once in the details. Skipped
in the details now.
Artifacts only ever talked upward: height, ready, errors. Nothing was
sent down, so the host could not ask an artifact anything — which is the
one thing standing between artifacts and the selection/context gestures
mini apps already have.

Adds request/reply with ids and timeouts. The guest dispatches to
handlers registered on window.__artifactHandlers and replies null for an
unknown method, so the host resolves instead of waiting out its timeout.

Two things that look wrong and aren't:

- Requests target '*'. The frame is sandboxed without allow-same-origin,
  so its origin is opaque and there is nothing to pin. postMessage on a
  specific contentWindow still reaches only that frame, and the existing
  per-render nonce remains what proves which render answered.
- A timeout resolves null rather than rejecting. The caller is host UI
  reacting to a pointer gesture, and "the page didn't answer" is a normal
  outcome for model-written HTML that may have thrown before registering
  a handler; rejecting would surface it as an unhandled error mid-drag.

The injected script stays dependency-free and still installs its error
listeners first, so it keeps winning the race against agent code.

Groundwork only — no caller yet. Selection over artifacts is next, and
it is what turns the shared transport from speculation into a refactor
over two real users.
The guest half of selection parity. Artifacts now report text highlights
upward and answer "what's inside this rectangle?" over the two-way
channel, so the gestures users already know from mini apps have something
to talk to.

The hit-test is ported from the mini-app SDK rather than reinvented:
same 60% containment threshold, same candidate selectors, same collapse
of nested matches to their outermost ancestor, same reading of a table
row as "Header: value" pairs so a bare number reaches the model attached
to the column it came from. Both surfaces should answer a marquee the
same way, and when the shared channel lands they will do it from one
implementation.

Selection is reported by the guest for the same reason it is in mini
apps: a host cannot read the selection inside a frame it shares no origin
with. That isolation is the point, so the page volunteers it.

Placed after the error listeners deliberately. Those must win the race
against agent code; this need not, and the harness stays dependency-free.

Verified in a real browser rather than by string assertions, since this
code is a vanilla-JS string that tsc never sees: an artifact rendered in
a sandboxed frame still reports ready and height, resolves a marquee to
real rows, and replies null to an unknown method instead of hanging.
The host half. Highlight text in an artifact and you get "Ask about
this"; drag a box and you get chips — the same two gestures a Mini App
has, using the same overlay and the same popover, because a user who
learns a gesture on one surface should find it on the other.

The overlays move out of src/mini-apps into src/components/embedded.
They were already generic; with two consumers, leaving them where they
were would have meant artifacts importing from mini-apps, which is
backwards. They now speak a neutral SurfaceRect rather than the mini-app
protocol's type.

SandboxedHtmlFrame gains two optional props: a selection notification,
and a callback handing out a marquee resolver. The resolver is passed out
rather than exposed on a ref because the nonce and the frame element are
both private to that component and the caller only needs the one
question. It's rebound per document, so a resolver captured before a
reload answers empty instead of returning the previous artifact's rows.

SelectableArtifact models the marquee as one state machine — idle,
drawing, reviewing — rather than a boolean plus a nullable array. The
pair-of-flags version this mirrors let a result bar sit pinned over an
active marquee, which is a bug the review found on the Mini App side and
which is unrepresentable here.

Wired into the side panel first, where quotes have an obvious home: the
artifact came from the chat the panel is open beside, so there's no
session to mint. The inline card is next and needs a little more thought,
since a transcript full of Select buttons is not obviously what we want.
Mini Apps and artifacts ask their frames questions over completely
different envelopes — JSON-RPC pinned to an origin, versus a nonce-stamped
message to an opaque frame — but the awkward middle was identical and
written twice: mint an id, hold the resolver, always settle, never settle
twice, release everything on teardown.

That middle now lives in one place. Envelope and trust check stay with
each surface, because those genuinely differ and pretending otherwise is
how a shared module becomes a pile of conditionals.

Worth noting this was deliberately not done first. A week ago artifacts
were one-way, and extracting then would have meant abstracting over about
ten common lines for a guest that had no requests to correlate. Doing it
after selection landed meant drawing the shape from two real callers.

Falls out of the move: artifacts installed a window listener per request
and now route replies through the one the frame already had, and both
surfaces get abortAll() rather than two hand-rolled teardown loops.

Coverage moves with the code — correlation, timeouts, double-settle and
teardown are tested once against the registry instead of partially at each
call site. What stays in the harness test is the envelope it still owns.

Not fixed here, and still outstanding from the review: the mini-app
listener effect depends on `theme`, so a theme toggle tears it down and
abortAll() cancels genuinely in-flight tool calls. The shared registry
makes that behaviour explicit rather than incidental, but the bug is the
dependency, not the teardown.
Comment thread src/components/embedded/pending-requests.ts Fixed
@darkbanjo
darkbanjo marked this pull request as ready for review August 31, 2026 18:24
Asking "what does this chart show?" used to make the model re-read the
HTML it generated — verbose, often no longer in context, and describing
the page as authored rather than as it stands after the user has clicked
around in it.

Artifacts now publish a digest of what they render, and `get_app_context`
serves it. Same tool name the Mini App version claims: to the user both
are "the thing on my screen", only one embedded surface is open at a
time, and the model shouldn't need a different verb per rectangle.

The digest is DERIVED, not authored. The page's author is the model,
which has never heard of this API — asking it to opt in would work for
new artifacts and never for the ones already sitting in a transcript.
The harness reads the title, the heading outline and the body text; a
page that wants to do better can set window.__artifactContext.

Re-sent on mutation, debounced, so an artifact whose tab the user
switched reports what it shows now. That's the part re-reading the source
HTML could never give you.

When no context has arrived the tool says so and tells the model to admit
it can't see, rather than falling back to guessing from the source — a
confident wrong answer about a chart on screen is worse than none.

Verified in a browser again, and it caught a real bug: `\n` written once
inside the harness template literal is consumed by TS and reaches the
injected script as a raw newline, breaking the string it sits in. tsc
can't see that, and the syntax error only surfaces at runtime as a
silently dead harness.
Which Mini App a chat was started from, so reopening it can bring the app
back. Many chats per app, so it lives on the chat rather than the app.

Modelled on project_id, and deliberately not a foreign key for the same
reason — plus a sharper one. A project is soft-deleted and its chats are
orphaned; a Mini App isn't in the database at all. The registry is
deployment config, so an app can disappear between releases, and the
chats that referenced it must survive and degrade to saying the app is
unavailable.

Not the two-PR synced-table flow: all three sync configs already select
`*` from chat_threads and shared/powersync-tables.ts lists table names
rather than columns, so a new column needs no rule change and no image
roll. That dance is for new tables. The ordinary constraint still holds —
this migration runs before anything writes the column.
A chat started beside a Mini App now remembers it, in both directions.

Writing it: the app page passes `miniAppId` down the same lazy path
`projectId` already takes — session → first save → row — because nothing
is persisted until the first message.

Reading it: the app's header gets a history menu of its own chats; the
sidebar row gets the app's icon; and `/chats/:id` gets a banner naming
the app with a link back that reopens *this* chat beside it.

Two things worth their comments in the code. Which chat is open on the
app page lives in `?chat=` once it's persisted, but stays local state
before that — an unsaved id in the URL would promise a thread that
reloading can't find, and hydration bounces those to Not Found. And an
app that's no longer registered is stated plainly rather than hidden;
that degradation is the whole reason `mini_app_id` isn't a foreign key.

The banner is gated on the Mini Apps flag: chats sync, the flag doesn't,
so one can arrive on a device where `/apps/:appId` isn't even a route.

Origin params on `getOrCreateChatThread` are grouped into an object on
the way past. A sixth positional would have made `(db, id, model, null,
null, appId)` a call site nobody can read.
Gated on viewport, not platform. The split view, highlight-to-ask and the
marquee are all pointer-first and need space, and a 700px browser window
is as unworkable as a phone — so the line is size, not device.
`useIsMobile` exempts the Tauri desktop app at any width, so narrowing the
desktop window keeps the feature.

Three places:

- The sidebar entry is hidden below the breakpoint, so there is no way in.
- `MiniAppPage` renders a size notice rather than the app. Deliberately a
  notice and not a missing route: a deep link out of a synced chat, or
  someone narrowing their window mid-session, should be told what happened
  instead of hitting Not Found.
- The chat banner keeps saying which app a chat came from but drops the
  "Open app" link, which would otherwise lead to that notice.

Deletes the mobile chat overlay from 25dbab5. It existed to make the
split survivable on a phone; with the route gated it can never render, and
dead code that looks load-bearing is worse than none.
Two findings from the branch review, both about a state the user reads
wrongly.

`parseGuestResult` accepted only the `result` form of a JSON-RPC reply, so
a guest that correctly reported a failure had its reply discarded as
unparseable and the request sat unsettled until its timeout. An app saying
"that tool threw" was indistinguishable from an app that had stopped
answering — fifteen seconds later. It now reads the `error` form too, and
the bridge settles it as a tool-shaped error result, which is what both
waiters already expect: `callTool` hands the message to the model,
`querySelection` fails its own parse and falls back to an empty selection.
A malformed error object is still rejected rather than trusted.

The Mini Apps preview flag also now seeds from `initData` like the tasks
and voice flags, instead of a hardcoded `false`. Until the reactive
settings query resolved, the route did not exist, so a deep link to
/apps/:appId rendered Not Found and then swapped to the app once the flag
landed.

Worth recording that the review overstated that one: it claimed deep links
were *permanently* redirected. They are not — `NotFound` only navigates on
a button press, so React Router re-matches when the route table gains the
route. It was a flash, not a dead end. Fixed anyway, and the inconsistency
with the other two flags was the actual defect.
Four review findings in the config and API layer.

`z.string().url()` accepts `javascript:`, and an app's origin is written
straight into `<iframe src>` — where that scheme executes in our page
rather than in a frame. Origins and urls are now http(s) only.

Origins are also normalised to a serialized origin, because that is what
`event.origin` reports: never a path, never a trailing slash. The bridge
compares the two with `===`, so `https://app.example.com/` in config
produced an app that loaded and then silently ignored every message it
sent — the least debuggable outcome available.

The registry was parsed as one record, so a single malformed entry emptied
it and every app disappeared at once. That contradicted the function's own
docstring, which promised a typo in one app wouldn't take down the others.
Entries are validated individually now, and each drop is logged, since an
app quietly missing from the sidebar is otherwise a long afternoon.

`GET /mini-apps` checked `!user` but not `isAnonymous`, while its comment
said the listing isn't for anonymous callers. The token route always
checked; this one now matches. It had no tests at all, which is how the
gap survived — it has them now, including that a secret never reaches the
wire.

Secrets move to a 32-character floor, matching what the rest of the
codebase holds signing keys to. Both sample apps in `.env.example` already
clear it.
Three frontend review findings.

`theme` was a dependency of the message-listener effect, whose cleanup
calls `pending.abortAll()`. So switching to dark mode tore the listener
down and aborted whatever tool call was running — a state the user reads
as "the app broke", with nothing in the logs. It moves to a ref: the
handler still answers `initialize` with the current appearance, and
changes still reach the guest through the host-context effect that already
exists for exactly that.

Token minting moves off a bare `fetch` onto the app's `HttpClient`. That
was a house rule, but the substance is that the client attaches the bearer
token and device headers and refreshes on a 401 — building the request by
hand skipped all of it, so a token minted just after the session rolled
over failed with no recovery. Its tests now inject a fetch into the real
client instead of stubbing the global, so the auth path is exercised
rather than mocked away.

"Try again" on an empty marquee result set `isSelecting` without clearing
`picked`, so the result bar stayed up underneath the new overlay.
# Conflicts:
#	src/chats/detail.tsx
#	src/defaults/settings.test.ts
#	src/defaults/settings.ts
#	src/settings/preferences.tsx
Comment thread src/components/artifact/sandboxed-html-frame.tsx Fixed
- MiniAppView held the panel's chat state alongside the bridge lifecycle,
  the element picker and the split layout, so the one piece of logic every
  data-loss bug lived in was the one piece no test could reach
- clear the draft on onCreated: openChatId reads the same id either way, so
  a saved chat kept counting as a draft and reopening it hydrated an empty
  conversation over a thread with messages in it
- decide persisted from the chat session, which learns the moment the first
  send creates the row, instead of a close-time guess that was stale for a
  chat that started as a draft and had since been saved
- ChatUI is on the landing path, so a static import of the approval
  prompt shipped the whole Mini App surface to every user
- gate the lazy mount on a non-empty approval queue so viewing a chat
  never fetches the chunk; the queue already lives on the session
- the approval prompt read the app name off whichever app was mounted, so a
  tool answered after the route moved labelled one app's write with another's
  name, and an entry queued for a closed app rendered nothing while its turn
  stayed blocked for the full deadline; the name now rides the queue entry
- the icon lookup indexed an object with an operator-supplied key, so
  "icon": "toString" resolved to a truthy Object.prototype member and handed
  React something that isn't a component — a Map has no inherited members
- attaching a passage minted a fresh chat whenever the panel was shut,
  abandoning the conversation openChat would have resumed; both paths now go
  through one showChat that also decides URL vs. draft state
- Escape during a surface-selection lookup left the in-flight closure to
  attach a passage for a gesture the user had cancelled
- drop the spurious defaultSettingsVersion bump: the flag's add and removal
  cancel out, leaving the defaults identical to main
- `HttpClient` only applies a timeout when asked, so a server that accepted
  the connection and never answered left `loading` true for the life of the
  tab — which `MiniAppPage` renders as a blank screen with nothing logged.
  Every path out of `loading` is now finite.
- `ChatWithOrigin` decided the destination against an unanswered registry,
  which looks identical to a deregistered app: a cold deep link mounted and
  hydrated the chat, then redirected into `/apps/:id` once the list landed,
  tearing the session down and rebuilding it. A *failed* registry still falls
  through, since the conversation beats a spinner that may never resolve.
- Cover the states callers actually render, including the one the deadline
  exists for. The store is module-level, so tests reset it via
  `resetMiniAppsForTesting` rather than leaking an answer into later files.
`GET /mini-apps` is unauthenticated, so anything it recomputes is work an
anonymous caller can drive. Deriving the public registry from `Settings`
per request re-ran `JSON.parse` and the per-entry validation, re-emitting
the "Dropping <id>" diagnostic on every call — a startup log turned into
unbounded volume. `getSettings()` memoizes per process, so there was
nothing to recompute in the first place.
- the approval boolean collapsed four endings into one, so a prompt that
  timed out or was swept when the app closed was reported to the model as
  "the user declined" — a decision the user was never offered
- name the endings (approved/denied/expired/unavailable) and give each its
  own sentence, so the two that describe a vanished opportunity invite the
  model to offer the action again
- drop the now-unused getPublicMiniApps wrapper left over from parsing the
  registry once per route
- escape the tool-list fence markers in app-authored descriptions, so a
  description cannot close the fence and continue in the system prompt
- require url and origin to parse as http(s), since url reaches an iframe
  src where a javascript: URL runs in our page, and the backend a client
  talks to is a local setting
- refuse an app served from our origin: the frame's allow-scripts plus
  allow-same-origin pairing is only safe across origins
- the dev fallback for MINI_APPS registered two apps living outside this
  repo, on ports a fresh checkout runs nothing on, and disagreed with the
  MINI_APPS line in .env.example; both now name the published starter
  template, so one default and one working app
- the registry store carried a load across resetMiniAppsForTesting, so a
  fetch begun in one file could setState after the next file reset it —
  roughly one randomized run in four. Loads now check a generation, and
  the timeout test flushes the rejection instead of asserting on the tick
- docs: say that desktop is blocked by the missing frame-src rather than
  merely per-build, list the guest messages and files the feature really
  has, and drop the retired experimental-flag and mobile-overlay prose
- `auth.api.getSession` throws an `APIError` for a credential Better Auth
  rejects, so the Mini App token route reported a stale key as a 500. It
  resolves through the exported `resolveAuthSession` now, like every other
  route that reads its own session, with a test pinning the status.
- Two copies of the same Origin predicate is one too many for a security
  check: `isRequestOriginAllowed` now serves both the token route and
  PowerSync.
- Projects and Mini Apps each carried the same chats-by-last-activity
  query. The fork was justified on their delete lifecycles differing,
  which is true and lives in the mutations — what the duplication put at
  risk was the `MAX(id)`-as-timestamp trick and the missing `LIMIT`, both
  of which now have one home. Ties break on id for a stable order.
- `supportedProtocolVersions` is a set, so a future envelope bump gets an
  overlap window instead of hard-rejecting every deployed guest.
- The artifact sidebar drags the whole element-picking stack into the
  entry bundle for a panel that opens on a click; it loads lazily.
- `download.ts` claimed every file save goes through it, and the PDF
  viewer's blob anchor says otherwise. The doc now says text only.
… pair

- an app route hosts the content-view panel, so an artifact opened from an
  app's chat leaves both surfaces live; `get_app_context` preferred the app
  unconditionally on a premise buried in an `else if`. `chooseEmbeddedSurface`
  makes the rule nameable and testable, and both stores now record `openedAt`
  so the most recently opened surface wins
- `navigateOnCreate` and `onCreated` always moved together, and the fourth
  combination — suppress the navigation, supply no callback — silently created
  a real thread nothing recorded. One prop now carries both meanings
- the wire shape of `GET /mini-apps` was written twice, as a
  `PublicMiniApp` type on the backend and a `miniAppResponseSchema` on
  the client: structurally coupled, free to drift, and silent when it did
- both ends now import one zod schema from `shared/`, kept apart from
  `mini-app-protocol.ts` because that is the postMessage contract with a
  customer's app while this is HTTP between our own two halves
- "selection" was doing double duty for two unrelated gestures: a text
  selection the user drags, and an element the user clicks to pick. Rename
  the picking half (useElementPicking, SurfacePickedElement, toPickedPassage)
  so the two stop reading as one feature
- replace EmbeddedSurfaceStatus's `failed` boolean with a named
  `waiting | failed` state — its only caller reached it by double negation
  from a three-member union
- drop the dead `tool.description` guard in the approval prompt: the schema
  requires a non-empty description, so a tool without one never renders
- prefer `state` over `s` in store selectors, and spell out reducer action
  names that had drifted from the fields they write
Every path here answered a failure with the right value and no record of
it, so a correctly-built app that never got an identity, a write nobody
approved, or a handler that threw all looked identical to nothing
happening.

- parse the token response instead of casting it: `.json<MiniAppAuthToken>()`
  asserted a shape it never checked, so `{ token: 7 }` reached the guest
  where a JWS was expected
- give the message listener a rejection boundary; an async listener's throw
  was an unhandled rejection that also left the guest waiting for its own
  timeout
- report a guest handler that throws as a runtime error, keeping the `null`
  reply so the protocol stays as narrow as it was
- hand MINI_APP_TOKEN_EXPIRY_SECONDS to the schema raw, so a typo is a loud
  boot failure rather than NaN collapsing into the default
- move the log levels to what they mean: a rejected artifact and an
  unconfigured registry are both normal, and `error` is for our own faults
- press the approval buttons: eleven structural assertions would all have
  passed with the two handlers swapped or `onClick` dropped
- assert the bridge's three deadlines against the exported constants, so a
  changed timeout can't leave a hardcoded number silently passing
- give chat destination its own tests — two callers navigate on its answer
  without being able to check it
- settle via the shared clock instead of a guessed microtask count, which
  stops waiting long enough the moment a call gains an `await`
- reset the artifact-context singleton on both edges; a stale title leaks
  `get_app_context` into later test files
- await the two `rejects.toThrow` assertions that were never awaited
- assert the harness got a reply at all, not just a null element
- the sidebar's chat-click handler depended on `chatThreads`, so its identity
  churned on each live-query result and re-rendered every memoized row; rows
  now pass their own `miniAppId` up instead
- compute an element's label once per hit-test rather than three times, and
  put coordinates ahead of it in the id so two long-labelled elements can't
  truncate to the same id under the host's 200-char clamp
- name the pick-overlay's label flip threshold and gap, and the selection
  debounce, so the bare numbers stop reading as arbitrary
- route the remaining `/apps/:id` string builds through `miniAppPath`
- drop the mini-app protocol type aliases nothing imports
- centre the selection popover on a rect where centring and clamping differ,
  so the test can fail for its own reason
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants