This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
chore: sync upstream superset-sh/superset @ cb3268fdf - #8
Merged
Merged
Conversation
* fix(ui): keep the maxFiles budget in step within a single tick
attachmentCountRef is assigned during render, so two add() calls before
the next render both budgeted against the pre-add count and could
together exceed maxFiles. Advance the ref as each batch is admitted; the
render-time assignment still re-syncs it from state afterwards.
Raised by CodeRabbit on #6939. Its suggested fix was to validate inside
the setAttachmentFiles updater, which would put the onError call in an
updater React invokes twice under StrictMode and double every toast, so
the budget is corrected in place instead.
* fix(ui): keep the attachment count honest for every mutation, not just add
Second CodeRabbit finding on this PR: remove, clear, takeFiles and
setFiles all changed the list without touching the reservation, so a
same-tick `clear(); add(...)` budgeted against the pre-clear count and
could reject files that now fit.
Rather than patch each mutator, make the single funnel synchronous: keep
a mirror of the list, compute the next value from it, and land both the
mirror and the count before the call returns. add() no longer has to
advance the count itself, and remove/clear/takeFiles/setFiles are correct
for free. The render assignment still re-syncs from state.
Verified in the app: two same-tick adds of 3 against maxFiles={5} give 5;
clearing the tray reports 0; a fresh batch of 5 afterwards is admitted in
full.
* fix(ui): take the submitted files from the synchronous mirror
Third CodeRabbit finding, and the worst of them: takeFiles still read the
render-time ref, so a submit in the same tick as an add returned the
pre-add list and then cleared the files it never handed back. The v1
new-workspace modal calls takeFiles() synchronously on submit, so a fast
paste-then-send could drop the attachments silently.
Reads the mirror instead. That also makes the second ref redundant, so
the two are collapsed into one and the unmount cleanup uses it too.
…atch 4) (#6942)
* feat(i18n): localize string props, toasts, and label maps across the desktop app
Batch 4 covers what the JSX-text passes deferred: placeholders,
aria-labels, tooltips, row labels and hints, dialog titles, confirm
copy, and toast messages, plus the module-level label maps and helper
functions that build visible strings (status labels, browser error
maps, automation templates and schedule presets, board lanes, plan
tables, hotkey names). Module data uses msg() descriptors rendered
through i18n._(); components and hooks use useLingui()'s t().
The display-only invariant holds: rawErrorMessage(), logs, analytics,
thrown errors, and server-wording matchers stay English.
Also shims i18n._ in the desktop bun-test preload. The compiled macro
turns message templates into placeholder messages plus values, so the
catalog stores "{minutes}m"; uncompiled test source has neither, and a
real i18n._ would render the placeholder empty.
Catalog grows 2041 -> 4402 messages.
* chore: keep the local CDP demo script out of the repo
* fix(desktop): match settings top bar to sidebar * fix(desktop): align settings chrome backdrop
… (#6943)
* feat(i18n): finish the sweep and ship Japanese and Simplified Chinese
Completes Phase 2 by converting every remaining surface — the web app,
marketing site, docs, admin, the Expo mobile app, packages/ui,
packages/chat-ui, packages/shared, and the Electron main process
(application menu, tray, native dialogs, notifications) — and then ships
the first two translations.
Catalog: 2041 -> 6882 messages, fully translated into ja and zh-CN.
compile --strict now gates every enabled locale, so an untranslated
message fails the build.
Locale support:
- SUPPORTED_LOCALES gains ja and zh-CN, with native labels in one shared
source that Settings and onboarding both read.
- resolveLocale matches regional tags to variants, so zh-Hans-CN and bare
zh land on zh-CN instead of falling through to English.
- Optional onboarding language step, gated on more than one locale
shipping so it disappears rather than offering a choice of one.
- formatRelativeTime/formatCompactRelativeTime replace hand-rolled
"3d ago" helpers; Intl already knows every locale's wording.
- @superset/i18n/server carries the RSC wiring: server components resolve
Lingui through the react-server condition, where <Trans> reads a
React.cache slot and throws unless setI18n has run.
Two fixes worth calling out. Changing the language never re-activated the
Electron main process, so the app and tray menus would have stayed in the
boot language forever; applyAppLanguage now re-activates and rebuilds
them. And a design-page string contained showArrow={false}, which ICU
parsed as an unsupplied placeholder and silently dropped — the code
fragment is now passed as a value.
The display-only invariant holds throughout: logs, Sentry/PostHog,
thrown errors, and server-wording matchers stay English.
* fix(i18n): follow the app locale when formatting schedules
describeSchedule and formatDateTimeInTimezone defaulted their locale to
the runtime default, so an automation row rendered a translated sentence
around an OS-language weekday: "每Sunday9:00 AM". Both now fall back to
the active i18n locale.
* style(marketing): drop an em dash Vale flags in linted copy
* fix(i18n): re-activate the inferred locale when switching back to Auto
The provider only called initI18n when an explicit locale was set, so
picking "Auto (system)" after a pinned language left the old catalog
active until the next restart. Auto is a choice, not an absence of one.
…al machine (#6858) * feat(host-service): add /tcp/:port WebSocket bridge for port forwarding Bridges one client WebSocket to one TCP connection on 127.0.0.1:<port>. Only ports the port scanner attributes to the requested workspace are reachable, so a workspace can forward what it started and nothing else on the host. Frames stay binary and under the relay's 1 MiB ceiling; a slow peer pauses the socket instead of growing memory. Runs behind the existing wsAuth middleware, so a stream that arrives through the relay dial-back carries the host secret like terminals do. Claude-Session: https://claude.ai/code/session_01NxTU235jowe5cpVsG47JqW * feat(desktop): forward remote workspace ports over the relay Adds a ForwardTransport interface in the main process with one implementation, RelayForwardTransport: each accepted local TCP connection becomes one relay2 dial-back stream to host-service's /tcp/<port>. The interface leaves room for Direct (Tailscale, VPN, LAN) and SSH transports without touching the manager or the UI. PortForwardManager owns the local listeners. sync() is the single entry point: it stops forwards outside the requested set and starts the missing ones on the same port number. A taken local port reports busy with the owning process when Superset started it; the desktop never remaps on its own. Listeners are dropped on before-quit. Exposed to the renderer as the portForwards tRPC router. Claude-Session: https://claude.ai/code/session_01NxTU235jowe5cpVsG47JqW * feat(desktop): auto-forward the selected remote workspace's ports Selecting a v2 workspace on a remote host forwards each of its detected ports to the same local port; leaving it stops them. Port rows show the state (forwarded, mapped to another port, busy, error). A busy row offers "Stop local <process>" when Superset owns the local process, and "Use another port" otherwise. The renderer pushes the relay JWT to the main process from setJwt, since main has no auth client of its own. RemotePortForwarder reads the live pathname itself; the layout's fuzzy matchRoute keeps its previous value after leaving the workspace route. Docs updated; ExecPlan moved to plans/done with outcomes. Claude-Session: https://claude.ai/code/session_01NxTU235jowe5cpVsG47JqW * feat(desktop,host-service): multiplex all of a workspace's forwards over one relay stream Proposal on top of the per-connection design: keep the manager, the busy-port UX, the ownership rule, and the ForwardTransport seam exactly as the PR built them, but carry every forwarded TCP connection as a numbered stream inside one WebSocket per (host, workspace) instead of one relay dial-back each. The wire protocol ships once — hosts update slowly, so the per-connection framing would have had to be supported long after we replaced it, which is why this lands as a reshape of the branch rather than a follow-up. Framing lives in @superset/shared/port-forward-mux: OPEN/OPENED/OPEN_FAIL/ DATA/EOF/CLOSE/WINDOW plus session HELLO/PING/PONG, 256 KiB frames, per-stream 1 MiB credit windows (credits issued only as bytes drain into the local socket, so TCP backpressure propagates end to end and per-stream in-flight is bounded — the relay-side memory hazard goes away with it). The relay needs no changes and its single-use dial tickets keep their existing lifetime: the mux stream is spliced verbatim like a terminal's. Host: /tcp/:port is replaced by /fwd?workspaceId= behind the same wsAuth; ownership is checked per OPEN against the same portManager attribution, so a port that appears mid-session forwards without a reconnect. Desktop: RelayForwardTransport opens the session lazily and probe() warms it, so a host without forwarding support errors the row at sync time with "update the host-service" instead of failing the first click. Also fixed in the manager, with regression tests: a sync landing during the probe no longer orphans a bound listener; a transient stream failure no longer wedges an active forward in error (next success restores it); and each window now has its own wanted set with union reconcile, so two windows stop tearing down each other's forwards — a window's set is released when its subscription closes, which also stops forwards leaking after the dashboard unmounts. The ports-provider gate now requires the selected workspace to be remote, so a local selection no longer switches on cross-host port polling. Measured end to end through production relay2 against a Linux host running this branch's bundle: cold session dial 991 ms paid once per workspace; warm stream open p50 166 ms (vs ~1 s per connection before); single-stream throughput 5–8.9 MB/s (the prior 1.9 MB/s was per-frame overhead — coalescing lifted it); 26.5 MB/s aggregate across 4 streams; and echo RTT while a forward pushed 82 MB through the same host DO held at p50 73 ms / p95 175 ms, i.e. terminals do not degrade under a saturated forward. Claude-Session: https://claude.ai/code/session_01KULRerZzybjtLsoUuMow3q * fix(host-service): dial the scanner's detected address, not 127.0.0.1 Vite 8's default `localhost` bind can resolve to the v6 loopback only, and a hardcoded 127.0.0.1 dial gets ECONNREFUSED on a port the scanner correctly attributed — hit on the first real dev server we forwarded. The mux route now connects to the address the scanner saw; wildcard binds (`*`, 0.0.0.0, `::`) still map to 127.0.0.1. Docs gain a note on what the remote server sees (loopback client address, TCP only). Claude-Session: https://claude.ai/code/session_01KULRerZzybjtLsoUuMow3q * chore(desktop,docs): address review nits — chunked echo reads, tunnel-timing wording, plan supersession note The test round-trip helper now collects TCP chunks until the full echo arrives instead of asserting on the first data event; the docs say the tunnel is established at workspace selection (probe warms it) rather than on first connection; the completed plan gains a note that the /tcp/:port host leg was superseded by the mux before merge. Claude-Session: https://claude.ai/code/session_01KULRerZzybjtLsoUuMow3q * chore(desktop): fix merge-resolution formatting in the port hover row Claude-Session: https://claude.ai/code/session_01KULRerZzybjtLsoUuMow3q --------- Co-authored-by: Satya Patel <satyapatel111@gmail.com>
…ut (#6950) * fix(marketing): seed RSC i18n per route entry, not just the root layout Every server component using <Trans>/useLingui() threw on client-side navigation because the RSC i18n slot was only seeded in the root layout. Claude-Session: https://claude.ai/code/session_01AnoeAZJf7hbF8uXC4Efrqb * test(i18n): anchor the RSC seeding check to a real call and import Addresses a CodeRabbit finding: a plain substring match would be satisfied by a mention in a comment or string literal. Claude-Session: https://claude.ai/code/session_01AnoeAZJf7hbF8uXC4Efrqb
* feat(desktop): copy-on-select setting for v2 terminals Adds a Terminal setting that copies the selection to the clipboard as soon as it is made, matching Ghostty's copy-on-select = clipboard and iTerm2's default. Off by default, so Cmd+C stays the only way to copy unless the user opts in. Trailing whitespace is trimmed per line before copying, the way Ghostty does. Writes are skipped while the window is unfocused (Chromium rejects clipboard writes from an unfocused document), and an unchanged selection does not re-hit the clipboard. Wired into the v2 TerminalPane only. The v1 terminal is being sunset, so it does not get the setting; the settings item is registered as a v2 variant. Original implementation by VelkyVenik in #2965, redone against v2. Closes #2803 Claude-Session: https://claude.ai/code/session_01RKLwT35VxLMjHdS8hXmuob * feat(desktop): show a Copied pill when copy-on-select fires Copy-on-select has no other feedback: the selection reaches the clipboard without the user pressing anything, so nothing on screen says it happened. A small pill fades in at the bottom right of the terminal pane and clears 1.2s after the last copy. A drag copies many times in a row, so each copy restarts the timer instead of stacking its own, and the pill stays up rather than flickering. A rejected clipboard write does not flash it. The tick lives in a per-pane store rather than TerminalPane state, so the flash re-renders the pill and not the whole pane. Claude-Session: https://claude.ai/code/session_01RKLwT35VxLMjHdS8hXmuob * i18n(desktop): translate the copy-on-select strings for ja and zh-CN main shipped the ja and zh-CN locales while this branch was open, and compile --strict fails on any missing message, so the three new strings needed real translations rather than empty entries. Claude-Session: https://claude.ai/code/session_01RKLwT35VxLMjHdS8hXmuob
…948) * fix(desktop): notify for OpenCode permission and question prompts * fix(docs): generate .source after next typegen so tsc reads a complete file `next typegen` loads next.config, which calls fumadocs-mdx's createMDX() and rewrites .source/server.ts. Running it after the fumadocs-mdx CLI meant tsc could start while that rewrite was still landing. `export const docs` is the last line of the generated file, so a partial write drops it entirely and tsc reports TS2306 "File '.source/server.ts' is not a module". Run the fumadocs-mdx CLI last so the final write completes before tsc starts. Claude-Session: https://claude.ai/code/session_01JrNWq5kNKdHw6B8YfSsDHB
…(#6957) * fix(auth): remember the organization you switched to across sessions The active organization lived only on the session row, and nothing set it when a session was created. A new session started with `active_organization_id` NULL, and the resolver filled that NULL with the user's *most recently joined* membership — so signing in again silently moved you into whichever organization you had joined last, not the one you were working in. Persist the choice on the user instead. `users.last_active_organization_id` is written whenever `/organization/set-active` succeeds, and the resolver now tries, in order: the session's own organization, the user's last switch, and only then the newest membership. Membership is re-checked at each step, so leaving an organization still falls through cleanly. Better-auth has no set-active hook and no built-in persistence for this (their docs point at `databaseHooks.session.create.before` plus your own lookup), so the route is the choke point — every client reaches it through `organization.setActive`. * fix(auth): remember the organization an accepted invitation lands you in Accepting an invitation already sets the new session's active organization. Record it on the user too, so the next session resumes there rather than falling back to whatever they were in before the invite. * fix(auth): make the invitation last-active write best effort The invitation is already accepted and the session already created by this point, and the cookie and one-time token cleanup still have to run. A failure here returned 500 with no cookie set and the token left behind, so retrying hit "already accepted".
…ck (#6956) Pi redraws with a synchronized update that ends in ED3 (`CSI 3 J`). Through @xterm/xterm 6.1.0-beta.289 that handler trimmed the scrollback and clamped ydisp but left `isUserScrolling` armed, so a viewport the user had scrolled away from stayed frozen there and never followed new output again. beta.302 clears the flag, scoped to the normal buffer so an alt-screen ED3 does not yank the user back to the bottom. Bump every @XTerm consumer together: desktop, host-service and shared take the version directly, and mobile's vendored WebView bundle is regenerated via scripts/generate-terminal-html.ts. Leaving mobile behind fails sherif's multiple-dependency-versions rule. The regression test drives the real Pi sequence through @xterm/headless and asserts the viewport re-pins once output resumes. Verified it fails on beta.289 (viewportY 0 vs baseY 16) and passes on beta.302. Claude-Session: https://claude.ai/code/session_01MMw6eiUadkffRh52GZFJAo
… stranding translations (#6959) * fix(i18n): catch English edits that leave translations behind Explicit message IDs are loosely coupled to their text, so editing English copy in source does not by itself change what ships, in two separate ways. `msgstr` in locales/en is what renders, and plain `lingui extract` only fills in new IDs — an edit to an existing message left the old English shipping. Extraction now runs with `--overwrite`, which rebuilds the source locale from source code every time. Nothing then invalidates ja/zh, and `compile --strict` still passes because nothing is missing, so a translation of the old wording ships silently. check-stale-translations.ts compares the branch against its merge base and fails when a message's English moved and a translation did not. Edits that genuinely do not invalidate a translation go in locales/en-only-changes.txt, keyed to the exact English text so the next edit is checked again. This rides on the existing "Audit i18n catalogs" step rather than adding one; that job's checkout needs fetch-depth 0 for a merge base to exist. An unresolvable base is a hard failure under CI and a local warning, so an unfetched history cannot report success. Claude-Session: https://claude.ai/code/session_0139hmqr3QyW9K3R3ghQLMp1 * fix(desktop): finish the Remote Access rename in the app #6896 renamed Remote Workspaces to Remote Access across marketing, docs and the README, but every app surface still said the old name: the settings section header and its nav entry, the command palette, the per-host section, the billing plans row, the paywall gate and its Pro feature preview, the relay toggle and its confirmation dialog, and the automations copy pointing people at "Settings > Remote Workspaces". Followed the rename into three places outside desktop that quote the desktop UI word for word and would otherwise now be wrong: the docs setup steps, both mobile host screens, and the Pro bullet in the subscription-started email. The Learn more links point at /remote-access directly instead of leaning on the 308 from the old path. Left alone: "a remote workspace" as a generic noun, meaning a workspace whose files live on another host. That is still what the sidebar badge, the Finder and external-editor guards, and the paste size limits are talking about. The catalogs also pick up a drift the new stale-translation check found on main, unrelated to this rename: web.design.aiAgentSection.55CallSitesPassShowarrow was fixed in source to pass showArrow={false} as a {code} value, precisely so ICU would not read {false} as a placeholder, but no catalog ever picked the fix up. All three locales were shipping the literal and rendering {false} as an empty placeholder. Claude-Session: https://claude.ai/code/session_0139hmqr3QyW9K3R3ghQLMp1 * fix(desktop): catch the wrapped Remote Workspaces path in the relay notice CodeRabbit spotted one the rename sweep missed: JSX had wrapped the string as "Settings > Remote\n Workspaces", so a line-based grep never saw it and the remote-host branch of RelayOfflineNotice still sent people to a settings section that no longer exists under that name. A multiline sweep confirms it was the only one. The stale-translation check added in this PR then flagged ja and zh-CN for that message, which is the mechanism working as intended: fixing the English alone would have left both locales pointing at the old section name. Also keep "workspaces" as a search keyword for the relay toggle. The matcher requires every term to occur, so dropping the old plural from the title and description made a search for "remote workspaces" stop finding the setting. Claude-Session: https://claude.ai/code/session_0139hmqr3QyW9K3R3ghQLMp1
* perf(i18n): load translation catalogs on demand Every catalog was statically imported, so each surface bundled all of them. At three locales that is ~1.1 MB of translations in every bundle; at the eighteen the locale expansion needs it would be roughly 10 MB. English stays bundled: it is the fallback for any message a translation is missing, so it has to be present synchronously. The rest load on demand through a spelled-out import map, since bundlers only follow import() calls they can resolve statically. initI18n keeps its synchronous signature for call sites that cannot await — module scope, and anything needing a locale before first paint — activating English first and swapping in when the catalog resolves. Lingui emits a change event on that second activation, which I18nProvider already listens for. initI18nAsync is the awaited form, and three call sites need it: the Electron boot path, the RSC entry point, and applyAppLanguage — that last one matters, because it rebuilds the application and tray menus, which resolve their labels once and would otherwise rebuild in English before the catalog arrived. * fix(i18n): seed the RSC i18n slot synchronously Making initServerI18n async broke every server-rendered <Trans>: the React.cache slot has to be seeded during the render pass that reads it, so an awaited version let rendering start first and the marketing build failed with "useLingui in a Server Component, but i18n instance for RSC hasn't been setup". English is bundled and activates synchronously, which is what makes a synchronous seed possible at all. A non-default locale needs its catalog loaded first, so preloadServerLocale covers that case for whenever per-request locale routing lands. * fix(i18n): degrade to English when a lazy catalog import fails A rejected dynamic import had no handler, so a corrupt install or missing chunk fired an unhandled rejection, which can terminate a Node process. English is already active at that point; log and stay there. Claude-Session: https://claude.ai/code/session_01GDauSyH3W1UG85a8Hmdooe
…ery Next app (#6960) * ci: smoke test deployed routes and arm the RSC seeding guard everywhere Two gaps let MARKETING-67/68/69 reach production. The pipeline deployed and never requested a page, so a build that returned 500 on every route still went green. scripts/smoke-routes.ts now walks each deployed preview, as a document and as an RSC request, and fails on a 5xx or on a 200 carrying an error digest, which is how a caught server-component error presents. It waits for the alias to resolve first, because a gate that flakes is a gate someone turns off. The seeding guard only covered marketing. It now covers web, admin, and docs too, and arms itself: an app whose translated markup is entirely in client components has nothing to seed, so the check stays quiet until the first server-rendered Trans appears, then requires every route entry in that app to seed. Verified by adding one to web, which flagged all 21 unseeded entries. Scope worth stating: the smoke test sends RSC without a router state tree, so it does not reproduce the layout pruning that caused the incident. A hand-built tree 500s against a healthy deploy and its shape follows the Next version, so it would fail for the wrong reason. Pruning stays covered by the static guard; the smoke test covers what only breaks once deployed. Claude-Session: https://claude.ai/code/session_01GDauSyH3W1UG85a8Hmdooe * fix(ci): make the smoke test actually detect the failures it claims to Two defects found by testing the failure paths rather than the happy one. The error-digest check never fired against a real deployment. Next embeds flight data as an escaped JSON string, so the payload reads \"digest\":\"…\" and the unescaped-only pattern missed every real error page. The earlier proof passed because the fixture was shaped to match the pattern instead of matching production. Now matches both forms. A listed route returning 404 counted as a pass, though it means the page is gone or failed to render into one. Now a failure. Redirects still pass: auth-gated routes legitimately send you to sign-in. Verified against a local server for each shape: 500, 404, RSC-only 500, escaped digest, and a server-components render error all exit 1; a healthy route and a 302 exit 0. Production still passes clean. Claude-Session: https://claude.ai/code/session_01GDauSyH3W1UG85a8Hmdooe * fix(ci): wait out alias propagation before smoke testing A fresh Vercel alias serves 404 until it propagates, and the readiness gate only retried on 5xx, so the admin smoke test ran against the unpropagated alias and failed on a healthy deploy. Ready now means a real page or an auth redirect, not merely the absence of a server error. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor * fix(ci): fail the smoke test on any 4xx, not only 404 A 401 or 403 on a listed public route means the route got gated, which is as broken as a missing page. Redirects still pass. Per review. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
* fix(host-service): preserve selected provider profile * fix(desktop): sync sidebar state across windows * fix(host-service): harden profile pointer migration
* feat(i18n): ship French Adds fr to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete 6,888-message catalog. Includes the Remote Access rename delta: the renamed ids are translated fresh, and the eleven strings whose English changed under a stable id (the Settings tab name, the relay toggle label and hint, the mobile connect-host path) are retranslated rather than carried over stale — a new locale's catalog predates the rename, and the stale-translation diff rule only sees PRs that change English. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor * style: fix catalog map indentation Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds ko to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete 6,888-message catalog. Sense-checked against cross-chunk prefill rather than accepting it: Open is an action (열기) on a button but a state (열림) on a PR badge, and Discard is 버리기 throughout, never 취소, which stays reserved for Cancel. Includes the Remote Access rename delta with the eleven changed-English strings retranslated rather than carried over stale. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
…lete in the grid (#6932) * feat(pages): default new pages to org, add cached thumbnails, owner, delete Publishing defaults to `org` so a page shared from the CLI or an agent is readable by the organization instead of silently invisible to everyone but its publisher. Existing pages are untouched: `updatePage` only patches visibility when the input supplies one, and there is no backfill. Thumbnails are captured once per page version into a disk cache in the main process and served over a new `superset-thumb://` scheme, replacing the grid's live per-card iframes. Cards now also show the owner when the viewer is not the owner, and expose Delete in the overflow menu. Also folds in the in-flight share/comment fixes on this branch: the page surface now reads its session from the desktop bearer-token auth client rather than the cookie-based web one, visibility changes apply optimistically and copy the link, and the comment overlay stops re-measuring on every frame. Claude-Session: https://claude.ai/code/session_015YytmsAiiZcTVrLwFSVpMF * fix(pages): address review on thumbnail caching and share popover Scope the thumbnail cache to the signed-in account. The cache key, on-disk path and `superset-thumb://` URL all carry an account id now, so cached renderings of a private page cannot be served to a different account sharing the same profile. The protocol handler still authorizes nothing by itself, but a path it will serve is no longer derivable from a page id alone. Fix two ways a wrong image could be cached permanently. `page.pull` was called without a version, so its five-minute cache could hand back the previous version's HTML after a republish and store it under the new version's key. A failed navigation was also swallowed, and since an error page is a non-empty bitmap the blank capture passed the empty-image check and was written to the cache. Navigation failure now aborts; the load timeout still captures whatever rendered, which is the case it exists for. Prune by access time rather than write time: reads now touch the file, so eviction is least-recently-used as intended instead of oldest-written. Also: sync the route scope when the pinned tab empties so pinning a page later does not snap the view back, key the share popover's pending visibility to the page id so it cannot leak across pages, let the owner label shrink before truncating, and reconcile the remote-image guidance in the page skill with its offline checklist. Claude-Session: https://claude.ai/code/session_015YytmsAiiZcTVrLwFSVpMF * fix(pages): guard blank captures and recover from evicted thumbnails A load timeout still captures rather than failing: `loadURL` resolves on did-finish-load, which waits for subresources, and the page CSP permits remote images and fonts — so a fully painted page with one hanging remote asset never resolves. Failing there would deny those pages a thumbnail forever. Instead the timeout path now refuses to persist a capture that came back uniformly blank, which is the condition actually worth guarding; a page that loaded cleanly is trusted even if it is a solid colour. The prune can delete a file whose `superset-thumb://` URL is still held by a query with infinite freshness, leaving a remounted card pointing at a 404 with no way back. The image now recovers by dropping both cached queries so the peek re-runs and recaptures, once per mount so a persistently failing capture cannot loop. Claude-Session: https://claude.ai/code/session_015YytmsAiiZcTVrLwFSVpMF * fix(pages): stop the thumbnail spinner when page.pull fails A cache miss enables page.pull, which can legitimately fail — it throws NOT_FOUND when the blob head() fails or the page has no versions. The capture query then stays disabled for want of a downloadUrl, and a disabled query reports pending forever without ever reporting an error, so the card span indefinitely instead of falling back to its placeholder. Treat an error from any of the three queries as terminal. Claude-Session: https://claude.ai/code/session_015YytmsAiiZcTVrLwFSVpMF * test(pages): cover the thumbnail cache boundary Sixteen tests over peekThumbnail, ensureThumbnail and the protocol handler, each verified to fail when the behaviour it guards is reverted: loosening the identifier pattern breaks the traversal tests, dropping the utimes call on serve breaks the recency test, and unscoping the cache directory breaks the account isolation tests. The traversal cases go through peek/ensure rather than the protocol handler. The handler takes its key from a parsed URL, which normalises `..` away on its own, so a traversal test there passes with or without the guard. peek and ensure take the key straight from tRPC input with no such normalisation, which is where the guard actually earns its place — those tests seed a file under a second account and assert the crafted key cannot reach it. `pageThumbnails` reaches electron through a namespace import so the module loads whatever shape the mocked module has. Several suites narrow-mock electron and then call `mock.restore()`, after which named imports resolve against the real CJS package and fail to bind; with static named imports these tests loaded fine alone but silently never ran in the full suite. Claude-Session: https://claude.ai/code/session_015YytmsAiiZcTVrLwFSVpMF * test(pages): drop wall-clock polling from the recency test The handler now awaits the access-time write instead of firing it and forgetting it, so the test can assert mtime directly rather than polling stat behind a setTimeout for up to half a second. Not firing and forgetting costs nothing here: the handler already awaits a readFile of the whole JPEG, next to which a single metadata syscall does not register. The original form was an optimisation that bought no latency and made a real side effect observable only by waiting on the clock. Claude-Session: https://claude.ai/code/session_015YytmsAiiZcTVrLwFSVpMF * refactor(pages): fold page content and thumbnail into one page router Both are Electron-main page plumbing (capture loads through the superset-page:// handler), so a second top-level page* router fragmented the domain. Compose them as content and thumbnail sub-routers under a single page router, matching one-router-per-domain like workspaces and terminal. Rename only: procedure bodies, input schemas, and the main/lib implementations are unchanged. Claude-Session: https://claude.ai/code/session_01UmoQ9JzwQgdXfwiXGk5NWj * feat(pages): restore scroll position when reopening a page Reopening a page dropped the reader back at the top. The content lives in an iframe sandboxed without allow-same-origin, so the host cannot read its scroll; report it over the comment runtime bridge instead, which already listens to scroll for pin repositioning. Positions are kept in a session-scoped map keyed by page and version, so a republished page starts at the top and nothing is written to disk. Restoring re-applies while the document grows, capped at a second, and aborts on the first wheel, touch, or key so it never fights the reader. The frame withholds scroll reports while a restore is pending, otherwise the initial zero would overwrite the saved offset before it is applied. Claude-Session: https://claude.ai/code/session_01UmoQ9JzwQgdXfwiXGk5NWj * i18n(pages): translate the delete action and toast (ja, zh-CN, ko, fr) --------- Co-authored-by: Satya Patel <satyapatel111@gmail.com>
…gent (#6952) * feat(pages): watch published pages and route comments to the owning agent An agent publishes a page, a teammate comments, and until now nothing carried that comment back unless a human opened the desktop hand-off menu and pushed it into a terminal by hand. This adds a watcher: a background poller in host-service that holds "page P is watched by the agent in terminal T", notices new human comments, and types them into that terminal. The agent triages what it receives and answers with the CLI commands it already has. host-service is the only process that can see both halves — it already holds an authenticated cloud client and it already owns the terminals — so the whole loop is one ticker in one process, with no new transport. Design record: plans/20260828-pages-comment-watcher.md Cloud: - publish no longer wipes agentActivatedAt. The agent's own republish used to revoke its permission to reply, so the documented publish-then-reply loop returned FORBIDDEN. Activation is now granted by a human writing on a thread. - pageComment.list returns threads from every version, with the version and authorKind attached. A thread bound to the version it was written on vanished the moment the page was republished, taking the reply that answered it. - page.setWatch / page.clearWatch write two nullable columns on pages so a reader's browser, which has no host access, can show that an agent is listening. Liveness decays from the heartbeat, so the badge stops claiming a watcher after the terminal dies. - activate is deleted. Activation now happens on human write, which left it with no callers in the repo. host-service: - PageWatchManager: Map<pageId, WatchEntry>, one shared unref()'d ticker that starts on the first entry and stops on the last, two-tier cadence, reentrancy guard, and an AbortController around in-flight cloud calls. Structure follows port-manager; lifecycle follows PullRequestRuntimeManager. - Bounded by construction: 20 watchers per host, a 2h idle TTL, a stop after 5 consecutive failures, and 5 pings per thread per version. The last one exists because CLI agent attribution is a claim rather than proof, so a misattributed reply could otherwise re-arm the trigger forever. - Terminal exit sweeps every page that terminal owned; each tick also reconciles against live terminals, so a missed exit event cannot strand a watcher. - EventBus gains onTerminalLifecycle, mirroring the existing onWorkspaceChanged. CLI: - pages publish registers the watcher when it has a workspace and a pane id, and --no-watch opts out. A failed registration never fails the publish. - pages watch list / stop, so a watcher can be inspected without the desktop app. UI: - CommentsSidebar lists every thread across versions and groups the ones whose anchor no longer resolves. Those threads render no pin, so before this they were unreachable rather than merely hidden. - PageWatcherMenu replaces PageHandoffMenu: the verb changes from a one-shot send to a standing assignment, with reassign and stop. - A chip on the terminal pane shows what that agent watches, and a read-only badge on the web page shows that an agent is listening. Also fixes the i18n check, which was silently a no-op: lingui gates its CLI bootstrap behind import.meta.main, which is undefined in Node below 24, and the lingui shim runs under node. The catalog could drift with CI still green. Forcing the bun runtime with `bun --bun` makes the check enforce again. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * fix(pages): poll watched pages every 5s instead of 10s Halves the worst-case delay between a reader leaving a comment and the agent being told about it. Only the active cadence changes — a page with no human comment in the last 5 minutes still decays to a 60s poll, and the heartbeat still writes at most once per 30s, so the cloud write rate is unchanged. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * fix(pages): stop attributing an agent's reply to the person who owns the credential A CLI agent authenticates as its user, so `reply` stores authorUserId = that user and the comment list joins their name and avatar onto the row. author_kind was recorded correctly as "agent" (verified against a real reply); nothing rendering the thread looked at it, so the agent's reply appeared under the reader's own name and face. commentAuthor() is now the single place that answers "who wrote this", and both the pin popover and the sidebar go through it. An agent comment renders as "Agent" with a bot glyph and no avatar rather than borrowing a human identity. The sidebar had the same bug wearing a label: it showed an "agent" badge next to the human's name and picture. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * fix(pages): tighten the terminal watch chip and its tooltip The chip printed the page title into a cramped pane header, truncated at 6rem, and the tooltip then repeated that same title inside a sentence with no width cap — so a long title produced a very wide card restating what the chip already said. The multi-page branch stacked bare titles with no hierarchy. Now the chip is the eye glyph plus a count only when more than one page is watched, and the tooltip is capped at max-w-64 with a Plural heading over a muted, truncated list. Matches the neighbouring connection indicator, which pins its own width for the same reason. role="img" on the trigger so the aria-label is valid on a non-interactive element; a button would have implied an action the chip does not have. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * fix(pages): hold delivery while the agent is busy, and never drop a failed send Two problems in one code path. The cursor and ping counts were committed before sendToTerminal was awaited, and a failed send was caught and logged, so a comment that never reached the agent was still marked delivered and never retried. The existing test asserted the watcher survived the throw, which read as robustness while pinning the data loss. Nothing commits now until the send resolves. A failed delivery leaves the cursor behind and is retried on the next tick, bounded by the same consecutive failure count that already guards the list call, so a terminal that cannot be written to stops being watched rather than retrying forever. Delivery also waits for the agent. TerminalAgentStore already tracks lastEventType per terminal, the signal behind the working/idle dots, so the watcher holds while an agent is working or sitting on a permission prompt — the case that mattered most, where injected text answers a prompt rather than queueing. Comments accumulate through the hold and arrive as one batch. Deferral is re-evaluation, not a queue: each tick recomputes what is undelivered from the cursor, so holding is just declining to commit. Two backstops, because the busy signal is not trustworthy on its own. An agent that never reports reads as free and is interrupted exactly as before, and an agent whose Stop hook never fires (interrupts fire no hook, which is why clearWorkspaceStatuses exists) is delivered to anyway after MAX_HOLD_MS. A page holding undelivered comments also stays on the fast cadence. Quietness was measured from the last successful send, so an agent busy for longer than IDLE_AFTER_MS decayed its own page to a 60s poll while a comment sat waiting. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * feat(cli): filter comments to unresolved, and sweep every page at once `pages comments list` could only be pointed at one page and printed every thread regardless of state, so answering "what is still open" meant running it per page and reading the open/resolved column by eye. --unresolved (alias --open) filters to threads nobody has closed. --page is now optional: without it the command sweeps every page the caller can see, tagging each thread with the page it belongs to, and --workspace narrows that sweep. The cross-page form is a query per page, which is fine at CLI scale but is why it stays opt-in rather than the default for a single page. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * fix(pages): cap the busy hold at 2 minutes Ten minutes meant a comment could sit undelivered for most of a long agent task. Two minutes bounds the wait at something a person watching the page would tolerate. The consequence is deliberate: the hold is now shorter than IDLE_AFTER_MS, so an agent working longer than two minutes gets interrupted rather than waited out. Deferral now avoids the short interruptions and permission prompts it was aimed at, not every interruption. That also makes the pending-page cadence guard in isDue unreachable — nothing can stay pending long enough for its page to be judged quiet — so the test asserting it has been removed rather than left claiming a scenario that cannot occur. The guard stays, so isDue remains correct if the constants move again. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * fix(pages): only agents can be watchers, and stop skipping live threads Three defects found in review, plus the stray file that should never have been committed. A terminal was assignable as a watcher whether or not an agent was running in it. SUPERSET_TERMINAL_ID is set on every terminal, so publishing from an ordinary shell registered that shell, and a reader's comment was then typed into it and submitted — the hold could not help, because a shell has no agent binding, so isAgentBusy read it as free. assign now refuses a terminal with no live agent binding, and a watcher whose agent has gone is dropped on the next tick rather than written to. The suppressed cursor was committed before the send while the fired cursor was committed after. They are independent maxima, so a ping-capped thread carrying a newer comment than a live one advanced the cursor past the live one, and that comment was never delivered — on both the hold path and the retry path. The suppressed cursor now moves only when there is nothing to deliver, or together with a successful send. registerWatch swallowed every error, including the watcher cap the manager takes care to explain. Publish now reports why it is not watching and still succeeds, since a page that published but is not watched is not a failed publish. watchTerminalId also stopped preferring SUPERSET_PANE_ID, a removed v1 variable holding a pane id rather than a terminal id. Dropped the comment on list() claiming an MCP agent only sees threads it was handed. Since create activates on the first human comment, every thread is born activated and that narrowing is now a no-op. ink-a-dark-theme-sample.html was a local test artifact swept in by `git add -A` in 7d80a181c. Untracked, left on disk. Claude-Session: https://claude.ai/code/session_015wHZeSUafJ58aKUsm7siNu * address CodeRabbit review comments - discard a poll whose entry was unwatched, replaced, or dropped while its listThreads/sendToTerminal was in flight; the stale continuation was heartbeating the cloud watch flag back on after the user stopped watching - strip control characters in the watch prompt: a comment body carrying the bracketed-paste terminator would close the paste early and land the rest of itself in the terminal as keystrokes - sort the CLI's page-less comment sweep by thread createdAt, so "oldest first" is true across pages and not just within one - plan doc: record the live end-to-end run, and correct the stale claim that pageComment.activate was retained Not taken: prompt-injection framing (accepted, org-visible pages are team-only) and rejecting MCP callers in pageComment.create (create is UI-only — CLI and MCP expose list/reply/resolve and no create — so the human authorKind and self-activation are correct by design). MD040 fences left bare to match the other 367 in plans/; no markdown linter runs in CI. * correct the plan's watcher invariants, and reword one zh-CN label - WatchEntry in the plan omitted lastHeartbeatAt and pendingSince, both of which the polling pseudocode below it already reads - the ping cap has no version dimension: pings is keyed by threadId and carries across reassignment, so a republish does not reset it - an agent reply only skips the trigger when SUPERSET_PANE_ID attributes it; an unattributed CLI reply records as human and can re-arm, bounded by the cap - zh-CN "关注评论的页面" parsed as "pages of the watched comments" * i18n(pages): regenerate catalogs and translate the watcher strings for ko and fr * i18n(pages): bring the watcher count message in line with its source in every locale * fix(desktop): use the desktop auth client in the page viewer after merging main --------- Co-authored-by: Satya Patel <satyapatel111@gmail.com>
…n (#6969) * feat(i18n): ship Traditional Chinese and fix Chinese locale resolution Adds zh-TW to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete 6,888-message catalog converted from zh-CN. The conversion is vocabulary-level, not glyph-level: Taiwan writes 軟體, 程式, 網路, 檔案, 記憶體, 專案, 搜尋, 控制項. Terms that are already correct were left alone rather than "corrected" into errors: 處理程序 (process), 文件 (document, as against 檔案 for file), 制 in 強制/限制, 准 in 批准. Also fixes resolveLocale, which a second Chinese variant breaks. Matching on the base language alone sends every Traditional preference to whichever zh-* comes first, so zh-Hant, zh-Hant-TW, zh-HK, and zh-MO all resolved to Simplified. Chrome and macOS report the script subtag, so this would hit most Taiwanese users on first launch. Resolution now reads script and region subtags and matches case-insensitively, with tests proven to fail against the old implementation. Includes the Remote Access rename delta with the eleven changed-English strings retranslated rather than carried over stale. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor * fix(i18n): ignore BCP 47 extension subtags in locale resolution zh-TW-u-nu-latn carries a numbering preference, not a different language, but the extension subtags defeated exact and alias matching and demoted the tag to zh-CN. Everything from the first singleton on is cut before matching. Per review. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor * fix(i18n): translate the pages-watch strings that landed mid-flight Main gained twelve strings from #6952/#6932 while this PR was in CI, and compile --strict correctly refused the merge ref. Converted from the shipped zh-CN with this catalog's vocabulary: 代理程式, 執行中, 傳送. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds es to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog in neutral international Spanish. Share as a usage-table column is Porcentaje, not the Compartir action; Free on a memory bar is Libre, not the billing Gratis; navigation paths inside strings are translated since the app they point at is localized. Includes the Remote Access rename delta and the pages-watch strings that landed after this catalog was translated. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds de to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. Register is du, matching German developer tools; buttons stay pronoun-free infinitives. Sense splits kept apart: Offen for a PR state against Öffnen for the action, Anteil for a usage column against Teilen for the share action, Tarif for a billing plan against Plan for an agent plan. Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds pt-BR to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. Brazilian, not European, keeping the English git vocabulary Brazilian developers use (branch, commit, merge, rebase, diff). Gender and number resolved per referent: Todas with branches and páginas, Todos with itens and PRs. Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
* feat(i18n): ship Italian Adds it to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. Keeps workspace, branch, commit, worktree, and repository in English, which is what Italian developers say and what fits the button widths. Adjectives agree with what they describe: Merged is Unita for a pull request, Unite for a filter. Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor * style: wrap the locales array Claude-Session: https://claude.ac/code/session_012U97YWVsDvZwQkBaSGEmor
Adds ru to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. All 104 plural messages carry one/few/many/other with the case Russian wants after a numeral (# файл / # файла / # файлов), verified programmatically. workspace is «рабочая область», leaving «рабочее пространство» free for a Slack or Linear workspace. Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds tr to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config,
with a complete catalog. Case suffixes never attach to placeholders:
a postposition where one fits ("{app} ile aç"), a colon where none does,
or a classifier noun that carries the suffix ("{0} otomasyonunu şimdi
çalıştır"). Brand-name suffixes hand-checked for vowel harmony
(Cursor'da, Copilot'ta, T3 Chat'te). Includes the Remote Access rename
delta and the pages-watch strings.
Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds pl to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. All plural messages carry one/few/many/other in the case a Polish numeral demands (1 plik / 2 pliki / 5 plików), including the fractional other-branch in genitive singular. Menu-bar entries take their conventional Polish names (Plik, Edycja, Widok, Okno, Pomoc). Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds nl to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. Register is informal je; the English dev vocabulary Dutch developers use stays English (branch, commit, merge, pull request, worktree) while settled Dutch forms are translated (werkruimte, sessie, automatisering). Weekday names are lowercase so they compose mid-sentence. Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds id to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. Keeps the English dev vocabulary Jakarta teams use with the hyphenated verb forms Indonesian developers write (di-merge, di-push, belum di-commit); translates where Indonesian is the norm (repositori, agen, otomatisasi). No plural split invented. Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
Adds cs to SUPPORTED_LOCALES, the lazy catalog map, and lingui.config, with a complete catalog. All plural messages carry one/few/many/other in the case a Czech numeral demands (1 soubor / 2 soubory / 5 souborů), with many as the fractional genitive-singular form. Zahodit for Discard with Zrušit reserved for Cancel. Includes the Remote Access rename delta and the pages-watch strings. Claude-Session: https://claude.ai/code/session_012U97YWVsDvZwQkBaSGEmor
…ed last (#7028) Remembering the active organization (#6957) hung off `/organization/set-active`, which the desktop's organization dropdown never calls: `switchOrganization` is window-local by design, so the surface people switch organizations in most was the one that never told the server. `users.last_active_organization_id` stayed NULL and every session fell through to the guess. The guess was the *newest* membership, which moves whenever somebody adds you to another organization, and it is written back onto the session row — so it then re-seeds every window that starts without an org of its own. Windows persist their own org across relaunches, which is why this looked intermittent rather than constant. The desktop now records the switch (serialised, so two quick switches cannot land out of order), and the last resort is the oldest membership — the only choice that does not move as you join more organizations. Both implementations of that fallback change together, the resolver and the `active` CTE the plan lookup shares, with an id tie-break for same-timestamp memberships. Sessions already stamped with a guess still win over the remembered value, by design; they heal on the first switch after this ships. Claude-Session: https://claude.ai/code/session_017EAxV43BZjTSBeC1vF6UQJ
…s, server thumbnails (spike) (#6954)
* feat(shared): usercontent contracts and the page comment runtime
Keys, manifest shape, HMAC view tickets, URL builders, the page CSP and
script injection for the usercontent origin, plus the comment runtime moved
out of packages/ui so the Worker can serve it.
* feat(usercontent): Cloudflare Worker serving pages from R2
One origin per page on *.supersetusercontent.com. Resolves a per-slug
manifest from R2, verifies view tickets for non-public pages, applies one
content policy for every viewer, injects the comment runtime, and serves
thumbnails. No API call at request time.
* feat(trpc,api): pages and chat attachments on R2; server-side thumbnails
Publish writes the version to R2, then the page's manifest, then queues a
thumbnail; visibility and shared-version changes rewrite the manifest;
delete removes everything. pull returns a presigned URL. Thumbnails are
captured by Cloudflare Browser Rendering through the usercontent origin.
* feat(web,desktop,ui): frame pages from the usercontent origin
Viewers load the page's own URL in a sandboxed iframe with
allow-same-origin and check the frame's origin on every message. Removes
srcdoc, the superset-page:// scheme, the pageContent router and the iframe
thumbnail cache; the grid shows the server-captured thumbnail.
* chore: env, deploy job and page skill for the usercontent origin
* feat(usercontent): pages sub-zone, ticket kind, secret rotation
Pages move to <label>.pages.supersetusercontent.com (Advanced Certificate
Manager covers the second-level wildcard). Tickets carry a kind so a page
ticket can never open something else, and the Worker accepts a previous
secret during rotation.
* feat(content): bring the page origin to the registry
The Worker is apps/content and the page id is the host label:
<pageId>.pages.supersetusercontent.com. Storage keys mirror the URL —
pages/<pageId>/manifest.json, versions/<n>/index.html,
versions/<n>/thumbnail.jpg — with the version number reserved before the
bytes move; a concurrent publish collides on the unique index (or finds
the target page changed) and retries under the next number. Paths and
names are readable throughout: /versions/<n>/, ?ticket=, claims
{kind, pageId, version, exp}; /versions/<n> without a slash redirects so
relative references resolve. The Worker binds the private bucket
(PRIVATE → superset-private); the API takes R2_PRIVATE_BUCKET.
setSharedVersion queues a thumbnail for the newly served version, and
the job skips captures that already exist.
* feat(content): path tickets, windowed expiry, cache headers
A private document now carries its ticket as a path segment —
/versions/3/~<ticket>/ — so when directory publish lands, every relative
reference resolves under the prefix and inherits the ticket; no cookie,
which Safari would block in our third-party iframe. The query form stays
for thumbnails and back-compat, and slashless forms redirect.
Ticket expiry is rounded to a window (an hour for the served alias, a
day for a pinned version), so identical claims give an identical URL
within the window and browser caches finally hit; the thumbnail ticket
in list is version-bound to get the day. Responses carry
Superset-Storage-Key (the cache key a client should use, never the URL)
and Origin-Agent-Cluster: ?1 while the PSL entry propagates; private
thumbnails cache for a day. list and pull return storageKey; previews
point at superset-private-dev with dev credentials; the desktop raises
Chromium's disk cache to 1 GiB so page video can cache at all. Ticket
verifier and URL tests cover the new forms.
* Merge main; drop the em dash the page skill's prose rule now rejects
The bundled-skills test added on main enforces the repo prose rule on
plugins/superset/skills/page/SKILL.md; one em dash from the earlier
merge resolution tripped it.
* fix(web): survive an empty USERCONTENT_URL in next.config
An unset GitHub Actions var arrives as an empty string, which `??` does
not catch, and `new URL("")` throws before Next loads the config — the
preview Deploy Web failure. Fall back on empty, not just undefined.
* fix(web): restore the translated anonymous-viewer fallback
The spike predated main's i18n sweep of the page route, so the merge
kept a hardcoded "You" and stranded web.page.anonymousUser in sixteen
catalogs — which is exactly what the Lint job's extract diff said.
* rename: the Worker app is apps/usercontent again
Satya's call: usercontent names what it serves more clearly than
content. Package @superset/usercontent, wrangler superset-usercontent,
deploy-usercontent job; env names never changed.
* dev(usercontent): allocated port + generated dev vars; zod env
The wrangler config keeps only topology — routeless dev env and the
bucket binding. Machine-specific values move where the rest of the repo
keeps them: setup's port allocator assigns USERCONTENT_DEV_PORT at
offset +14 and writes USERCONTENT_URL into the shared .env, and
scripts/dev.ts folds those (plus the .dev.vars secrets, since wrangler
reads .dev.vars.dev instead of .dev.vars for --env dev) into a
generated file and hands wrangler the port. A bare `bun run dev` still
works on the 8787/3000 fallbacks. The Worker's env is now a zod schema
(env.ts) — the single source of truth for the string vars, validated
once per isolate so a misconfigured deploy fails loudly on its first
request — with the PRIVATE binding riding on the inferred type, and the
type is UsercontentEnv again to match the app's name.
* chore(usercontent): alphabetize dependencies for sherif
* scrub(chat): remove the attachment chain nothing calls
chat.uploadAttachment carried base64 bytes through tRPC and the
/api/chat-attachments route streamed them back through the API — both
directions violate the one rule of the usercontent architecture, and
the whole chain has zero callers in any app. Chat attachments come back
on the files layer (presigned PUT, media host, tickets) with the chat
UI that needs them. The chat_attachments table (0046) stays for now;
dropping it is a follow-up migration once the stacked PRs land, so the
migration journals don't collide.
* swap(r2): the standard S3 client instead of hand-rolled signing
Satya's call, and the right one: @aws-sdk/client-s3 with Cloudflare's
prescribed checksum config (WHEN_REQUIRED both ways) replaces aws4fetch
behind the same eight-function facade — callers, mocks, and the
integration test never notice. Gains: DeleteObjects batching instead of
N requests, the industry client every maintainer knows, and lib-storage
multipart within reach for the resumable-upload follow-up. The presign
shapes are verified offline: the PUT signature covers
content-length;content-type, which is the upload size gate. R2_ENDPOINT
(optional) points the client at an S3-compatible emulator for tests.
* cleanup(pages): drop chat_attachments, rename blob_pathname to storage_key
Both are free while pages are dark. chat_attachments (migration 0046)
lost its last consumer in the attachment-chain scrub; the table goes
with it, along with the stranded serverError.chat.failedToRecordChatAttachment
key and its 17 catalog entries. page_versions.blob_pathname is a
Vercel-Blob-era name for what is now an R2 storage key — renamed to
storage_key per the design registry, as a true column RENAME because
pre-migration rows still hold Blob pathnames that migrate-pages-to-r2.ts
must read.
Note for the stack: this mints drizzle migration 0100 on the base PR, so
the files-layer PR regenerates its 0100 as 0101 when it rebases.
* docs(page-skill): the host label is the page id, not the slug
* docs(env): fake Cloudflare placeholders in the local template, per its own header
* rename(usercontent): pages.supersetusercontent.com -> frame.supersetusercontent.com
The origin is never user-facing, and "frame" names the general contract:
anything rendered in an iframe gets a per-item isolated origin here (HTML
attachment previews and future embeddable content, not just pages). The
PSL entry is the slowest artifact in the system, so it gets filed once for
the general namespace. Product word stays "Pages"; media. is unaffected.
* refactor(pages): derive PublishedVersion from the drizzle row types
* chore: remove planning-doc sources swept in by an over-broad git add
* docs(mcp): teach pages_publish the shipped content policy, not the old sandbox
* fix(pages): close the review findings that were real
- publish: upload inside the version-reserving transaction. Bytes used to
move before the insert, so two concurrent publishes could write the same
key and the loser's cleanup could delete the winner's committed object.
The insert now holds the unique (page, version) slot through the upload,
and the delete-cleanup is gone: a rollback strands at most one object
under a number the next attempt reuses.
- worker: a restricted thumbnail's Cache-Control now expires with the
ticket that fetched it instead of a flat day; authorized() returns the
verified claims to make that possible.
- delete fails closed: the manifest (the Worker's authorization source)
is removed first, so an interrupted delete 404s instead of serving a
deleted page; manifest writes get a short retry everywhere.
- desktop: a failed thumbnail load retries after a minute instead of
pinning the placeholder for the session.
- deploy-usercontent job: explicit contents:read token.
…7) (#7019)
* feat(shared): usercontent contracts and the page comment runtime
Keys, manifest shape, HMAC view tickets, URL builders, the page CSP and
script injection for the usercontent origin, plus the comment runtime moved
out of packages/ui so the Worker can serve it.
* feat(usercontent): Cloudflare Worker serving pages from R2
One origin per page on *.supersetusercontent.com. Resolves a per-slug
manifest from R2, verifies view tickets for non-public pages, applies one
content policy for every viewer, injects the comment runtime, and serves
thumbnails. No API call at request time.
* feat(trpc,api): pages and chat attachments on R2; server-side thumbnails
Publish writes the version to R2, then the page's manifest, then queues a
thumbnail; visibility and shared-version changes rewrite the manifest;
delete removes everything. pull returns a presigned URL. Thumbnails are
captured by Cloudflare Browser Rendering through the usercontent origin.
* feat(web,desktop,ui): frame pages from the usercontent origin
Viewers load the page's own URL in a sandboxed iframe with
allow-same-origin and check the frame's origin on every message. Removes
srcdoc, the superset-page:// scheme, the pageContent router and the iframe
thumbnail cache; the grid shows the server-captured thumbnail.
* chore: env, deploy job and page skill for the usercontent origin
* feat(usercontent): pages sub-zone, ticket kind, secret rotation
Pages move to <label>.pages.supersetusercontent.com (Advanced Certificate
Manager covers the second-level wildcard). Tickets carry a kind so a page
ticket can never open something else, and the Worker accepts a previous
secret during rotation.
* feat(content): bring the page origin to the registry
The Worker is apps/content and the page id is the host label:
<pageId>.pages.supersetusercontent.com. Storage keys mirror the URL —
pages/<pageId>/manifest.json, versions/<n>/index.html,
versions/<n>/thumbnail.jpg — with the version number reserved before the
bytes move; a concurrent publish collides on the unique index (or finds
the target page changed) and retries under the next number. Paths and
names are readable throughout: /versions/<n>/, ?ticket=, claims
{kind, pageId, version, exp}; /versions/<n> without a slash redirects so
relative references resolve. The Worker binds the private bucket
(PRIVATE → superset-private); the API takes R2_PRIVATE_BUCKET.
setSharedVersion queues a thumbnail for the newly served version, and
the job skips captures that already exist.
* feat(content): path tickets, windowed expiry, cache headers
A private document now carries its ticket as a path segment —
/versions/3/~<ticket>/ — so when directory publish lands, every relative
reference resolves under the prefix and inherits the ticket; no cookie,
which Safari would block in our third-party iframe. The query form stays
for thumbnails and back-compat, and slashless forms redirect.
Ticket expiry is rounded to a window (an hour for the served alias, a
day for a pinned version), so identical claims give an identical URL
within the window and browser caches finally hit; the thumbnail ticket
in list is version-bound to get the day. Responses carry
Superset-Storage-Key (the cache key a client should use, never the URL)
and Origin-Agent-Cluster: ?1 while the PSL entry propagates; private
thumbnails cache for a day. list and pull return storageKey; previews
point at superset-private-dev with dev credentials; the desktop raises
Chromium's disk cache to 1 GiB so page video can cache at all. Ticket
verifier and URL tests cover the new forms.
* Merge main; drop the em dash the page skill's prose rule now rejects
The bundled-skills test added on main enforces the repo prose rule on
plugins/superset/skills/page/SKILL.md; one em dash from the earlier
merge resolution tripped it.
* fix(web): survive an empty USERCONTENT_URL in next.config
An unset GitHub Actions var arrives as an empty string, which `??` does
not catch, and `new URL("")` throws before Next loads the config — the
preview Deploy Web failure. Fall back on empty, not just undefined.
* fix(web): restore the translated anonymous-viewer fallback
The spike predated main's i18n sweep of the page route, so the merge
kept a hardcoded "You" and stranded web.page.anonymousUser in sixteen
catalogs — which is exactly what the Lint job's extract diff said.
* feat(files): the files layer — schema, router, media route (SUPER-2077)
Architectural support for attachments on issues and chat, and the file
substrate page assets stand on. Bytes go direct to R2 by presigned PUT
(1 GiB, signature covering type and length); files.complete is the gate
a presigned PUT cannot be — HEAD confirms the size, a ranged read sniffs
the real type (magic bytes; anything that smells like a scriptable
document is named one), and only then is the file ready. The serve-time
policy keys on the sniffed type, carried in a day-rounded file ticket so
the media route needs no database: HTML and XML always download, SVG
renders only as a subresource image, PDF and media inline, everything
under nosniff and a sandbox CSP. media.supersetusercontent.com joins the
Worker with Range support so video seeks; access always derives from a
readable parent via the polymorphic attachments table (page_version now,
the other kinds when their surfaces ship — refusing loudly until then).
Pending rows older than a day are swept, objects first, via a
QStash-verified job. No UI ships here by design.
* chore(files): biome format
* fix(files): sniff without TextDecoder fatal mode
relay2 typechecks the trpc sources under workers-types, whose
TextDecoder takes no { fatal } — and the lenient decode with a
replacement-character check is simpler anyway: one U+FFFD is allowed
only at the very end, where the 8 KB window may cut a multi-byte
sequence.
* fix(usercontent): import the file-serving helpers the merge port missed
* chore: drop the deleted apps/content workspace from the lockfile and workflow
* fix(files): close the review findings that were real
- page delete now removes page_version attachment rows (no FK carries the
cascade) and deletes files — rows and bytes — that no other attachment
references.
- the pending-file sweep claims rows first (guarded on pending) and deletes
objects after, and complete's ready-flip is guarded on pending: the two
can no longer race a file into ready-with-no-bytes or deleted-but-served.
- a ticketed media response may live in the browser cache only until its
ticket expires, mirroring the thumbnail rule.
… folders) (#7022) * docs: generate weekly changelog 2026-08-30 Also adds a hero image to the 2026-08-27 SOC 2 Type II entry, which shipped without one. Claude-Session: https://claude.ai/code/session_011zKUF9njL63tLUpHmCaGPv * docs: clear Vale prose suggestions on the changelog entries Rewrites five passive-voice constructions in the 2026-08-30 entry and simplifies the SOC 2 entry's prose, which was over the Flesch-Kincaid target. Claims are unchanged. The one remaining suggestion is alex.ProfanityMaybe on "penetration test", the correct security term, left as-is. Claude-Session: https://claude.ai/code/session_011zKUF9njL63tLUpHmCaGPv
…, cli 1.25.0 -> 1.25.1, pty-daemon 0.3.1 -> 0.3.2) (#7029)
… tokens, circleback/granola drop (dark launch) (#6709) * feat(automations): trigger fidelity — providers, pickers, plan gates, sentry/webhook/gmail infra, row specs (squashed) * feat(db): drop circleback from automation_trigger_kind, prune its catalog entries Postgres can't remove an enum value in place, so 0100 recreates the type with the 10 live kinds, stepping the dependent partial indexes and CHECK constraint aside for the round-trip (drizzle-kit can't express that part; hand-extended and replay-tested on the dev branch). Catalog diff is the automations messages whose copy this branch rewrote — the dir is temporarily un-enforced and gets re-converted in a dedicated i18n pass. * fix(automations): tolerate both label shapes across the menu seam Main's converted grammars carry msg() descriptors while this branch's rewritten ones still carry plain strings; providerLabelText/labelText resolve either, so menus, keys, and the collapsed requires-connection row render both. * fix(trpc): keep joinSlackTriggerChannels from failing a committed save The connection lookup could throw after the trigger set already committed, rejecting a mutation that succeeded; joins now also run in chunks of five instead of all at once, since Slack rate-limits conversations.join.
…7040) Every plural message on a mobile surface throws while it renders. Hermes gives iOS most of Intl — Collator, NumberFormat and DateTimeFormat are all there — but not PluralRules, and Lingui compiles a plural to a runtime `new Intl.PluralRules(...)`. That is a TypeError, "undefined cannot be used as a constructor", raised during render. Nothing in the mobile tree is an error boundary, so it takes the screen down rather than degrading to the raw message. Twenty-three messages are affected today: search results, the commits list, hidden-line counts in a diff, the files-changed summary, pull request headlines and file counts, reasoning durations, test result counts. Opening a pull request from a workspace is the easiest way to hit it — the card's headline is a plural. This is not dev-only. Every EAS profile, production included, uses the same Hermes; there is no jsEngine override anywhere, so a shipped build has the same gap. The polyfill installs only when the runtime is missing the API, so a future Hermes that grows one keeps its own. Locale data is one file per base language, since plural categories do not vary by region. Verified on an iOS 26.5 simulator: before, `typeof Intl.PluralRules` is "undefined"; after, it is "function" and selects correctly across languages with different category sets — en 1/3 → one/other, ru 2 → few, cs 2 → few. Claude-Session: https://claude.ai/code/session_015DLMgqtnPGus5Az8tGkoAX
On the non-chained creation path the agent and the setup script are independent by construction, but the setup terminal was created first and so took the earlier createdAt — which is what every client's tab order follows. Opening a freshly created workspace put a setup shell in the first slot and the agent behind it. Only `waitForSetupBeforeAgents` (desktop-only, off by default) chained the two into a single terminal, so everything else — desktop's default, the CLI, MCP, automations, and mobile — got the setup shell in front. It reads worst on mobile, where the strip sorts oldest-first and the first tab is what you land on. Dispatching the agent first swaps only the creation order; both still run concurrently, and the chained path is untouched. Claude-Session: https://claude.ai/code/session_01U1PCJWKGgvDQk7NZn7VFCj
* fix(automations): four review findings on the trigger rows - the webhook copy-token button stayed clickable on a disabled row - TokenField stripped a leading @ from every value, so a branch named "@next" was stored as "next"; the strip is now opt-in and on for logins - clearing a custom RRULE showed the previous rule's next run instead of saying the edit cannot save - saving joins the bot to watched public channels, but the cached channel list kept botMember:false, so the membership warning outlived its fix * style: wrap the schedule problem condition biome's way * revert: leave the schedule row alone The cleared-RRULE fix touched ScheduleSentence, which is not flag-gated and whose copy is already reviewed. Dropping it here so this PR stays entirely within dark, unreviewed providers. * fix(automations): say so when a custom RRULE is cleared Clearing the field left a blank editor under the previous rule's next run, with nothing saying the edit could not save — an empty rule has no parse error to report, so it needed its own case. Wording follows the two messages already in this row. Ships unflagged, so the copy wants a review pass.
The "View PR" pill sat in its own row between the terminal and the composer, and the screen measured that row on layout to inset the terminal by it — 40pt of scrollback, on every workspace with a pull request. It becomes the one static control at the head of the session tab strip, where the row already exists and costs nothing more: the composer reports the whole tabs-keys-card cluster's height, so the chip is already inside the number the terminal insets by. It holds still while the tabs scroll under it, so a long strip never carries it out of reach. The chip draws lucide's own git marks, bundled as art. SF Symbols has no pull request — its nearest neighbours are all arrows, and none of them reads as the glyph GitHub taught everyone — so the four marks are rasterised from the same paths PULL_REQUEST_STATUS already names, and reach SwiftUI as local file URIs the way the session tabs' brand marks do. Rendered as templates, so one asset takes each state's tint. The SF Symbol survives as what the chip draws until the file resolves, the same role a session's initial plays behind its logo. The composer stays ignorant of pull requests: it takes a mark, a state name and a label, and reports that the chip was pressed. Which route the tap opens stays in React Native. The five state colours do now exist twice — the Swift enum is hand-kept in step with PULL_REQUEST_STATUS, the way ComposerSessionAttention already mirrors desktop's StatusIndicator. The action crosses the bridge as null rather than undefined when there is none. React Native drops undefined props before they reach the view, so the native setter never runs and a chip that has gone away stays on screen — caught by screenshotting the transition, not by any type. Routing is untouched: one pull request pushes the detail screen, several open the form sheet. The tap is captured by hand as pull_requests_opened because it lands in SwiftUI, where RN autocapture cannot see it. Trims aboveComposerHeight, its onLayout and the absolute wrapper that existed only to stack the pill over the composer. Verified on an iOS 26.5 simulator — open, draft, merged, closed and no-PR, plus the set-to-absent transition — alongside biome, mobile typecheck, bun test and lingui compile --strict across all 17 locales. Claude-Session: https://claude.ai/code/session_015DLMgqtnPGus5Az8tGkoAX
The effort picker stopped at xhigh, so the two levels above it were unreachable from the new-workspace modal even though codex-cli has accepted them for several releases. They are not available on every model, which is why they arrive with a restriction rather than as two more flat entries. Codex's own model catalog (`supported_reasoning_levels` in models_cache.json) says every GPT-5.6 model takes `max`, `ultra` — maximum reasoning plus automatic task delegation — is Sol and Terra only, and GPT-5.5 and below stop at xhigh. The CLI does not police the pairing: `-c model_reasoning_effort=ultra` with `--model gpt-5.5` launches happily and dies on the first turn, which in a workspace means an agent that looks started and never answers. So an effort option can now name the models that accept it, and the offered set follows the model picker. Absent models means every model takes it, which is every other entry in the catalog. An unset model keeps the full list: the launch omits --model and runs whatever default the CLI resolves, and so does an id outside the curated catalog, which buildAgentModelArgs already drops for the same reason. A remembered effort the current model rejects stays in localStorage but reads as "Default effort" and launches as one, so switching from Sol to GPT-5.5 downgrades visibly instead of sending ultra along. The host validates the pair too, so `superset agents create --effort ultra --model gpt-5.5` gets the supported list back rather than a broken session. Claude-Session: https://claude.ai/code/session_01JQ34D61zccarfUjbmdh477
…ER-2078) (#7020)
* feat(shared): usercontent contracts and the page comment runtime
Keys, manifest shape, HMAC view tickets, URL builders, the page CSP and
script injection for the usercontent origin, plus the comment runtime moved
out of packages/ui so the Worker can serve it.
* feat(usercontent): Cloudflare Worker serving pages from R2
One origin per page on *.supersetusercontent.com. Resolves a per-slug
manifest from R2, verifies view tickets for non-public pages, applies one
content policy for every viewer, injects the comment runtime, and serves
thumbnails. No API call at request time.
* feat(trpc,api): pages and chat attachments on R2; server-side thumbnails
Publish writes the version to R2, then the page's manifest, then queues a
thumbnail; visibility and shared-version changes rewrite the manifest;
delete removes everything. pull returns a presigned URL. Thumbnails are
captured by Cloudflare Browser Rendering through the usercontent origin.
* feat(web,desktop,ui): frame pages from the usercontent origin
Viewers load the page's own URL in a sandboxed iframe with
allow-same-origin and check the frame's origin on every message. Removes
srcdoc, the superset-page:// scheme, the pageContent router and the iframe
thumbnail cache; the grid shows the server-captured thumbnail.
* chore: env, deploy job and page skill for the usercontent origin
* feat(usercontent): pages sub-zone, ticket kind, secret rotation
Pages move to <label>.pages.supersetusercontent.com (Advanced Certificate
Manager covers the second-level wildcard). Tickets carry a kind so a page
ticket can never open something else, and the Worker accepts a previous
secret during rotation.
* feat(content): bring the page origin to the registry
The Worker is apps/content and the page id is the host label:
<pageId>.pages.supersetusercontent.com. Storage keys mirror the URL —
pages/<pageId>/manifest.json, versions/<n>/index.html,
versions/<n>/thumbnail.jpg — with the version number reserved before the
bytes move; a concurrent publish collides on the unique index (or finds
the target page changed) and retries under the next number. Paths and
names are readable throughout: /versions/<n>/, ?ticket=, claims
{kind, pageId, version, exp}; /versions/<n> without a slash redirects so
relative references resolve. The Worker binds the private bucket
(PRIVATE → superset-private); the API takes R2_PRIVATE_BUCKET.
setSharedVersion queues a thumbnail for the newly served version, and
the job skips captures that already exist.
* feat(content): path tickets, windowed expiry, cache headers
A private document now carries its ticket as a path segment —
/versions/3/~<ticket>/ — so when directory publish lands, every relative
reference resolves under the prefix and inherits the ticket; no cookie,
which Safari would block in our third-party iframe. The query form stays
for thumbnails and back-compat, and slashless forms redirect.
Ticket expiry is rounded to a window (an hour for the served alias, a
day for a pinned version), so identical claims give an identical URL
within the window and browser caches finally hit; the thumbnail ticket
in list is version-bound to get the day. Responses carry
Superset-Storage-Key (the cache key a client should use, never the URL)
and Origin-Agent-Cluster: ?1 while the PSL entry propagates; private
thumbnails cache for a day. list and pull return storageKey; previews
point at superset-private-dev with dev credentials; the desktop raises
Chromium's disk cache to 1 GiB so page video can cache at all. Ticket
verifier and URL tests cover the new forms.
* Merge main; drop the em dash the page skill's prose rule now rejects
The bundled-skills test added on main enforces the repo prose rule on
plugins/superset/skills/page/SKILL.md; one em dash from the earlier
merge resolution tripped it.
* fix(web): survive an empty USERCONTENT_URL in next.config
An unset GitHub Actions var arrives as an empty string, which `??` does
not catch, and `new URL("")` throws before Next loads the config — the
preview Deploy Web failure. Fall back on empty, not just undefined.
* fix(web): restore the translated anonymous-viewer fallback
The spike predated main's i18n sweep of the page route, so the merge
kept a hardcoded "You" and stranded web.page.anonymousUser in sixteen
catalogs — which is exactly what the Lint job's extract diff said.
* feat(files): the files layer — schema, router, media route (SUPER-2077)
Architectural support for attachments on issues and chat, and the file
substrate page assets stand on. Bytes go direct to R2 by presigned PUT
(1 GiB, signature covering type and length); files.complete is the gate
a presigned PUT cannot be — HEAD confirms the size, a ranged read sniffs
the real type (magic bytes; anything that smells like a scriptable
document is named one), and only then is the file ready. The serve-time
policy keys on the sniffed type, carried in a day-rounded file ticket so
the media route needs no database: HTML and XML always download, SVG
renders only as a subresource image, PDF and media inline, everything
under nosniff and a sandbox CSP. media.supersetusercontent.com joins the
Worker with Range support so video seeks; access always derives from a
readable parent via the polymorphic attachments table (page_version now,
the other kinds when their surfaces ship — refusing loudly until then).
Pending rows older than a day are swept, objects first, via a
QStash-verified job. No UI ships here by design.
* chore(files): biome format
* fix(files): sniff without TextDecoder fatal mode
relay2 typechecks the trpc sources under workers-types, whose
TextDecoder takes no { fatal } — and the lenient decode with a
replacement-character check is simpler anyway: one U+FFFD is allowed
only at the very end, where the 8 KB window may cut a multi-byte
sequence.
* feat(pages): directory publish — assets on the page's own origin (SUPER-2078)
A page can now be a directory: `superset pages publish ./report/`
serves index.html as the document and every other file at its relative
path, so <video src="demo.mp4"> streams by range from the page's origin.
Assets are files underneath — verified before the entry document moves,
attached to the version in the publish transaction, recorded in the
manifest per version — and the ticket rides the document's path, so
every relative reference inherits it with no cookie. HTML assets carry
the page policy like the document; style-src gains 'self' so a
directory's stylesheet loads. Republishing hashes each asset against
the previous version and reuses unchanged files instead of re-uploading;
a video the browsers cannot play (HEVC .mov and friends) gets a warning,
not a refusal. Deleting a page removes its versions' attachments and
any file left with no attachments at all. The CLI resolves the previous
version through page.resolveByEntryPath, which mirrors the republish
lookup; the page skill teaches the directory form.
* refactor(cli): publish command per the repo structure guidelines
command.ts orchestrates; each unit lives in its own utils/ folder with a
barrel, tests co-located. uploadAssets owns the hash-reuse lookup and the
upload loop; publishResult is the single place the result is shaped — the
data/message contract mirrors an artifact publish (what published, where
it lives, whether the session watches it), notes in one fixed order,
assets/watching always present in the JSON.
* fix(pages): assets attach only for their uploader or their page's lineage
verifyPublishAssets previously accepted any ready org file, so a member
could republish someone else's upload to a wider audience on an
`everyone` page. Now a file must be the caller's own upload, or already
attached to a version of the target page (the republish-reuse path).
Also pins the implicit publish behaviors with tests: the default
response shape and its fixed note order, singular/reuse wording,
one-version-deep reuse as a documented choice, lookup-failure
degradation, and PUT-failure error text — against a real local server,
no mocks.
* fix(pages): let a page load its own assets, not just https ones
img-src, media-src and font-src listed only scheme sources, so a
directory publish's own image or video loaded solely because production
happens to be served over https — the identical page served over http
blocked itself, and curl never saw it because CSP is a browser rule.
Adding 'self' matches what style-src already needed for the same reason.
Found by publishing a real directory through the API and opening the
served page in a browser.
* fix(pages): refuse symbolic links in a directory publish
`statSync` follows links, so a symlink inside the published directory made the
CLI read and upload a file from anywhere on the machine — and pages default to
organization-visible, so the file landed where the whole org could read it.
Every node is now stat-ed with `lstat` and a link is refused outright, at the
entry document and at each walked entry. Refusing beats skipping: a page
silently missing an asset it references is worse than one that will not
publish.
Verified against a directory holding a symlink to a file outside it — refused
by name; an ordinary directory still collects.
Also from review: the skill still described a page as one self-contained file,
which stopped being true with directory publish, and a rejection assertion was
never awaited so a failure could not be reported.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* fix(pages): drop em dashes from the page skill
The bundled plugin skills are covered by a prose test that forbids them, which
the previous commit's rewording tripped.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
…pload (SUPER-2080) (#7046)
* feat(shared): usercontent contracts and the page comment runtime
Keys, manifest shape, HMAC view tickets, URL builders, the page CSP and
script injection for the usercontent origin, plus the comment runtime moved
out of packages/ui so the Worker can serve it.
* feat(usercontent): Cloudflare Worker serving pages from R2
One origin per page on *.supersetusercontent.com. Resolves a per-slug
manifest from R2, verifies view tickets for non-public pages, applies one
content policy for every viewer, injects the comment runtime, and serves
thumbnails. No API call at request time.
* feat(trpc,api): pages and chat attachments on R2; server-side thumbnails
Publish writes the version to R2, then the page's manifest, then queues a
thumbnail; visibility and shared-version changes rewrite the manifest;
delete removes everything. pull returns a presigned URL. Thumbnails are
captured by Cloudflare Browser Rendering through the usercontent origin.
* feat(web,desktop,ui): frame pages from the usercontent origin
Viewers load the page's own URL in a sandboxed iframe with
allow-same-origin and check the frame's origin on every message. Removes
srcdoc, the superset-page:// scheme, the pageContent router and the iframe
thumbnail cache; the grid shows the server-captured thumbnail.
* chore: env, deploy job and page skill for the usercontent origin
* feat(usercontent): pages sub-zone, ticket kind, secret rotation
Pages move to <label>.pages.supersetusercontent.com (Advanced Certificate
Manager covers the second-level wildcard). Tickets carry a kind so a page
ticket can never open something else, and the Worker accepts a previous
secret during rotation.
* feat(content): bring the page origin to the registry
The Worker is apps/content and the page id is the host label:
<pageId>.pages.supersetusercontent.com. Storage keys mirror the URL —
pages/<pageId>/manifest.json, versions/<n>/index.html,
versions/<n>/thumbnail.jpg — with the version number reserved before the
bytes move; a concurrent publish collides on the unique index (or finds
the target page changed) and retries under the next number. Paths and
names are readable throughout: /versions/<n>/, ?ticket=, claims
{kind, pageId, version, exp}; /versions/<n> without a slash redirects so
relative references resolve. The Worker binds the private bucket
(PRIVATE → superset-private); the API takes R2_PRIVATE_BUCKET.
setSharedVersion queues a thumbnail for the newly served version, and
the job skips captures that already exist.
* feat(content): path tickets, windowed expiry, cache headers
A private document now carries its ticket as a path segment —
/versions/3/~<ticket>/ — so when directory publish lands, every relative
reference resolves under the prefix and inherits the ticket; no cookie,
which Safari would block in our third-party iframe. The query form stays
for thumbnails and back-compat, and slashless forms redirect.
Ticket expiry is rounded to a window (an hour for the served alias, a
day for a pinned version), so identical claims give an identical URL
within the window and browser caches finally hit; the thumbnail ticket
in list is version-bound to get the day. Responses carry
Superset-Storage-Key (the cache key a client should use, never the URL)
and Origin-Agent-Cluster: ?1 while the PSL entry propagates; private
thumbnails cache for a day. list and pull return storageKey; previews
point at superset-private-dev with dev credentials; the desktop raises
Chromium's disk cache to 1 GiB so page video can cache at all. Ticket
verifier and URL tests cover the new forms.
* Merge main; drop the em dash the page skill's prose rule now rejects
The bundled-skills test added on main enforces the repo prose rule on
plugins/superset/skills/page/SKILL.md; one em dash from the earlier
merge resolution tripped it.
* fix(web): survive an empty USERCONTENT_URL in next.config
An unset GitHub Actions var arrives as an empty string, which `??` does
not catch, and `new URL("")` throws before Next loads the config — the
preview Deploy Web failure. Fall back on empty, not just undefined.
* fix(web): restore the translated anonymous-viewer fallback
The spike predated main's i18n sweep of the page route, so the merge
kept a hardcoded "You" and stranded web.page.anonymousUser in sixteen
catalogs — which is exactly what the Lint job's extract diff said.
* feat(files): the files layer — schema, router, media route (SUPER-2077)
Architectural support for attachments on issues and chat, and the file
substrate page assets stand on. Bytes go direct to R2 by presigned PUT
(1 GiB, signature covering type and length); files.complete is the gate
a presigned PUT cannot be — HEAD confirms the size, a ranged read sniffs
the real type (magic bytes; anything that smells like a scriptable
document is named one), and only then is the file ready. The serve-time
policy keys on the sniffed type, carried in a day-rounded file ticket so
the media route needs no database: HTML and XML always download, SVG
renders only as a subresource image, PDF and media inline, everything
under nosniff and a sandbox CSP. media.supersetusercontent.com joins the
Worker with Range support so video seeks; access always derives from a
readable parent via the polymorphic attachments table (page_version now,
the other kinds when their surfaces ship — refusing loudly until then).
Pending rows older than a day are swept, objects first, via a
QStash-verified job. No UI ships here by design.
* chore(files): biome format
* fix(files): sniff without TextDecoder fatal mode
relay2 typechecks the trpc sources under workers-types, whose
TextDecoder takes no { fatal } — and the lenient decode with a
replacement-character check is simpler anyway: one U+FFFD is allowed
only at the very end, where the 8 KB window may cut a multi-byte
sequence.
* feat(pages): directory publish — assets on the page's own origin (SUPER-2078)
A page can now be a directory: `superset pages publish ./report/`
serves index.html as the document and every other file at its relative
path, so <video src="demo.mp4"> streams by range from the page's origin.
Assets are files underneath — verified before the entry document moves,
attached to the version in the publish transaction, recorded in the
manifest per version — and the ticket rides the document's path, so
every relative reference inherits it with no cookie. HTML assets carry
the page policy like the document; style-src gains 'self' so a
directory's stylesheet loads. Republishing hashes each asset against
the previous version and reuses unchanged files instead of re-uploading;
a video the browsers cannot play (HEVC .mov and friends) gets a warning,
not a refusal. Deleting a page removes its versions' attachments and
any file left with no attachments at all. The CLI resolves the previous
version through page.resolveByEntryPath, which mirrors the republish
lookup; the page skill teaches the directory form.
* refactor(cli): publish command per the repo structure guidelines
command.ts orchestrates; each unit lives in its own utils/ folder with a
barrel, tests co-located. uploadAssets owns the hash-reuse lookup and the
upload loop; publishResult is the single place the result is shaped — the
data/message contract mirrors an artifact publish (what published, where
it lives, whether the session watches it), notes in one fixed order,
assets/watching always present in the JSON.
* fix(pages): assets attach only for their uploader or their page's lineage
verifyPublishAssets previously accepted any ready org file, so a member
could republish someone else's upload to a wider audience on an
`everyone` page. Now a file must be the caller's own upload, or already
attached to a version of the target page (the republish-reuse path).
Also pins the implicit publish behaviors with tests: the default
response shape and its fixed note order, singular/reuse wording,
one-version-deep reuse as a documented choice, lookup-failure
degradation, and PUT-failure error text — against a real local server,
no mocks.
* fix(pages): let a page load its own assets, not just https ones
img-src, media-src and font-src listed only scheme sources, so a
directory publish's own image or video loaded solely because production
happens to be served over https — the identical page served over http
blocked itself, and curl never saw it because CSP is a browser rule.
Adding 'self' matches what style-src already needed for the same reason.
Found by publishing a real directory through the API and opening the
served page in a browser.
* refactor(pages): page-scoped asset staging, no generic file upload
Deletes the generic `file.*` router — a domain-less write path with a 1 GB
ceiling and no content-type policy, whose only consumer in the repo was the
pages CLI. Uploads now belong to the domain that owns them:
page.create({ workspaceId?, entryPath?, title? })
page.assets.upload({ pageId, path, name, contentType, sizeBytes, sha256 })
page.assets.remove({ pageId, path })
Assets stage against the page as `attachments(parentKind:'page')` and publish
snapshots them onto the version it mints, so a version never serves without
its own assets. There is no `complete` procedure: publish is already the gate
that must be correct, so it HEADs and sniffs the staged bytes there.
`fileId` no longer appears in any input or output, CLI JSON included. Reuse
moved server-side (sha256 within the page's own lineage), so the CLI no longer
enumerates files to deduplicate. Shared plumbing stays domain-neutral in
`lib/files/`; only policy is domained — page assets cap at 100MB.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* fix(pages): stage and create through the pooled client, renumber to 0103
Two defects a local end-to-end publish surfaced:
`page.create` and `stageAsset` opened transactions on `db`, the neon-http
client, which has none — every directory publish failed with "No transactions
support in neon-http driver". Both now use `dbWs`, the pooled client, which is
what `publishPage` already used.
The migration was generated as 0102 while main had since merged its own
0102_drop_circleback_trigger_kind, colliding on both the filename and journal
idx. Regenerated as 0103 after merging main.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* perf(pages): upsert staged assets, upload them concurrently
Publishing a thirty-asset directory took 21.5s. Two changes, both found by
timing a real publish rather than reasoning about it:
`stageAsset` was a delete-then-insert inside a pooled transaction. It is now a
single upsert onto `attachments_parent_path_unique`, which is atomic on its
own. That index is partial, so `ON CONFLICT` has to repeat its `path is not
null` predicate or Postgres cannot infer it — the first attempt without that
failed every publish.
The CLI then staged assets one at a time, and each stage is a round trip to
the API plus, on a miss, one to storage. The loop now runs eight wide.
30 assets: 21.5s -> 5.2s. Reuse and repointing verified unchanged at both
widths (0/30 on an unchanged republish, 1/29 with one file edited).
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* fix(pages): reject unknown publish keys, document asset handling
A newer CLI against an older API had its `assets` field silently stripped by
zod's default behaviour: the upload succeeded, the publish succeeded, and the
page shipped with every image missing and no error anywhere in the chain. This
is observable today — the Canary CLI does directory publish, production does
not have it, and a page published that way serves its document and 404s its
assets. `publishPageSchema` is now strict, so a version mismatch fails loudly
instead.
`pages publish --help` said nothing about assets, so the only way to learn that
a directory's relative references are uploaded was to guess. It now says so,
and names the default visibility, which was only discoverable from `pages get`
after the fact.
Also switches admin's `db.transaction` to the pooled client — same neon-http
defect fixed for pages, unrelated caller.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* fix(pages): refuse symbolic links in a directory publish
`statSync` follows links, so a symlink inside the published directory made the
CLI read and upload a file from anywhere on the machine — and pages default to
organization-visible, so the file landed where the whole org could read it.
Every node is now stat-ed with `lstat` and a link is refused outright, at the
entry document and at each walked entry. Refusing beats skipping: a page
silently missing an asset it references is worse than one that will not
publish.
Verified against a directory holding a symlink to a file outside it — refused
by name; an ordinary directory still collects.
Also from review: the skill still described a page as one self-contained file,
which stopped being true with directory publish, and a rejection assertion was
never awaited so a failure could not be reported.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* fix(pages): drop em dashes from the page skill
The bundled plugin skills are covered by a prose test that forbids them, which
the previous commit's rewording tripped.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
* fix(pages): three review findings on staging
Publish deleted every staged row for the page, but `staged` was read before
the transaction opened. An upload that staged a path in between was neither
attached to this version nor left staged for the next one: a silently dropped
asset whose file row the sweep collected a day later, with no error anywhere.
The delete is now scoped to the rows this version actually snapshotted.
`upload` returned from the reuse branch before the asset-count check, so a
caller could map one reusable file across unlimited paths and pass the cap. The
check moved ahead of the branch, where it covers both.
`page.create` linked a workspace path with a conflict target covering only the
primary key, so a colleague's page already holding that entry path surfaced as
an unhandled database error instead of the actionable CONFLICT `publish` gives.
Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
Auto-resume relies on the terminal's agent binding carrying an agentSessionId, which today only arrives from a lifecycle hook. Harnesses disagree on when they first report one: Claude's SessionStart fires at launch, but Codex's TUI fires nothing until the first turn (verified on codex-cli 0.151, idle TUI under a real pty for 25s emits only the wrapper's synthetic launch report, with an empty session id). So a codex pane that auto-resumed sat there with the full restored conversation and no session id, and dying a second time before the user's next prompt left it with no resume candidate and no pill. The host already knows the id in that case: it just launched the terminal with it, and `codex resume <id>` keeps the same rollout id (verified: the resumed pane reported the same id on its next turn). Bind it at launch instead of waiting for a hook. Claude-Session: https://claude.ai/code/session_01XbdkBBA2ASXGLPHXFBf5ZB
… compare page (#7031) * content: evergreen agent context-switching post and Orca vs Conductor compare page Both target non-brand search demand found in Search Console rather than repackaging changelog announcements. The blog post covers moving a session between agents: what context is made of, why transcripts do not port across harnesses, the fork and resume flags for six agent CLIs, and the alt-screen scrollback measurement. It targets the agent/context-switching cluster and reinforces the parallel-agents post. The compare page targets "orca vs conductor" (331 impressions, no dedicated page) and "conductor competitors" (1,883 impressions, 0 clicks). Competitor facts verified against current sources 2026-08-30; no star counts cited, since sources disagree. Claude-Session: https://claude.ai/code/session_011zKUF9njL63tLUpHmCaGPv * content: correct factual errors found in review Four Conductor claims were wrong, verified against conductor.build docs: it supports OpenCode as a fourth harness (not three), its mobile app is announced rather than shipped, agents can share a single workspace so the "never edit the same files" guarantee needed scoping, and Conductor Cloud runs in Conductor-managed sandboxes so the "all three run on your machine" claim needed the same. Also dropped an unverified Pro API claim. Blog fixes: a running dev server is process state and does not transfer with the directory; `grok import` is a real Claude Code to Grok importer, so the "no interchange format" claim is now scoped to vendor-neutral formats; a fork reads the saved session and does not need the source still running; the alternate-screen claim is mode-dependent (Pi's default regular mode uses the main buffer); the section promised resume commands the table never listed; and a transcript handoff ships unredacted terminal output to another vendor, which now says so. Claude-Session: https://claude.ai/code/session_011zKUF9njL63tLUpHmCaGPv * content: verify the Orca side against its own docs Applies the same sourcing standard to Orca that review forced on the Conductor claims. Corrected: Orca's mobile is shipped on iOS and Android, not an Expo app in beta, and Superset is iOS only, so the table now says so. Orca is MIT licensed. Its design mode opens a real Chromium window per worktree. The bigger fix is "Where Superset Fits", which listed PR review, design mode, remote hosts and mobile as Superset additions. Orca has all four, so those were not differentiators at all. The section now concedes that ground and keeps the two contrasts that hold against Orca's own docs: any CLI agent rather than a prebuilt list, and an MCP server plus TypeScript SDK so other software can drive it, where Orca consumes MCP without exposing one and is otherwise CLI-driven. Claude-Session: https://claude.ai/code/session_011zKUF9njL63tLUpHmCaGPv * content: drop the agent-agnostic differentiator against Orca Orca documents "Add a custom CLI agent" (name, binary, default arguments, optional startup hook), so "any CLI agent rather than a prebuilt list" was not a differentiator against them. Third overclaim of the same kind in this PR, all caught in review. The page now says Orca supports 25+ prebuilt agents plus custom CLIs, and states plainly that Orca and Superset are close on agent coverage while Conductor is the narrow one. Against Orca the remaining contrast is what can drive the tool from outside: an exposed MCP server and TypeScript SDK, Slack dispatch, and the enterprise track. Also removed dated "shipped last week" copy. Claude-Session: https://claude.ai/code/session_011zKUF9njL63tLUpHmCaGPv * content: fix the intro's leftover agent-list overclaim The opener still pitched Superset as the escape from "either vendor's agent list" after the body had already conceded that Orca supports custom CLI agents. Points at the MCP server and SDK instead, matching the rest of the page. Claude-Session: https://claude.ai/code/session_011zKUF9njL63tLUpHmCaGPv
The v1 client was not just a leftover path, it was the default: connect.ts probed /health and selected v1 for anything that was not `proto: 2`, which includes every probe failure. Hosts now always speak tunnel v2. Removes TunnelClient, its old test file, and the protocol negotiation in connect.ts; tunnel/index.ts exports TunnelClientV2 instead. DO NOT CUT A RELEASE containing this until relay.superset.sh points at the relay2 worker. A build with no v1 client that is handed the v1 relay has no tunnel at all, and the v1 relay is still the fallback whenever the relay-url-override flag is off (versionless CLI/headless identities, a fresh install before identify sets desktop_version) or PostHog is unreachable (resolveUserRelayUrl catches and returns env.RELAY_URL). Server-side v1-relay presence fallbacks and the relay-url-override flag are deliberately untouched; they still serve the <=1.20 fleet on relay1. Claude-Session: https://claude.ai/code/session_01SduXRDYRusSKRKs6sFg31t
Replaces the single-purpose auto-download interstitial with a browsable downloads page in the shape peers ship it: site header and footer restored, surface cards for Desktop, CLI and Mobile, and a release catalog built from the GitHub releases API. The catalog reads `desktop-v*` tags and groups each release's assets into per-OS columns with real sizes, latest expanded and older versions collapsed behind `<details>` so it works with no JS. Update manifests, the unversioned `latest` aliases and the electron-updater `-mac.zip` archives are filtered out. A failed or slow fetch (5s cap) degrades to a link rather than holding the render. Card visuals are drawn in code, not captured, so an evergreen page does not pin a version number, a shell prompt and real worktree paths. Desktop reuses AppMockup, the same component the front page hero uses. Two pre-existing bugs fixed: - Linux desktop has shipped since February (AppImage, with auto-update) but the page still told Linux visitors "Superset is Mac-only for now" and offered a waitlist. Linux now auto-downloads like macOS and the waitlist is Windows-only, matching the assets we actually publish. - The marketing site's fonts never applied. globals.css declared --font-family-sans / --font-family-mono, but Tailwind v4's namespace is --font-*, so every page fell back to the system stack while Inter and IBM Plex Mono were downloaded and unused. And two caught in review before merge: release dates were formatted without a pinned timezone, which would have been a hydration mismatch between UTC servers and other zones; and the auto-download fired before platform detection resolved, sending every visitor the Apple Silicon build. https://claude.ai/code/session_01DUN9fyJmJKwAkzgW55Efbe
* feat(desktop): group sessions into tag folders in the sidebar Sessions had no grouping: the "Sessions" lane was one flat list, and the new-group action bailed out early with a "Sessions get groups in the stacked nesting PR" comment. Projects already grouped via tag folders, so sessions now reuse that model instead of inventing a second one. Groups are derived from host-owned workspace tags rather than stored placement rows, because sessions have no project to hang folder settings off. A session carrying several tags lands in its alphabetically first normalized tag, which mirrors how project folders pick one winning tag and keeps each session in exactly one deterministic home. Untagged sessions stay at the top. The DnD model is intentionally untouched: Sessions stays a single persisted reorder/pin container, so buildDashboardSidebarSessions returns a flat orderedWorkspaces alongside the derived tagGroups. Also extracts DashboardSidebarGroupHeader as the shared header shell and promotes DashboardSidebarSectionHeader to the sidebar's components/ directory now that both lanes render it. onSetColor becomes optional since session groups have no project-scoped color settings. * fix(desktop): don't offer session groups on cloud workspaces Cloud rows are built with projectId: null and type: "worktree", so the new session-grouping branch keyed on `projectId === null` also caught them. Right clicking a cloud workspace and picking "New group from workspace" or "Move to group" wrote session tags to it while the row stayed in the Cloud section, so the action looked like a no-op but still mutated data. Thread an explicit isSessionWorkspace through the row so grouping keys on the workspace type rather than the absence of a project. The menu hides the group actions for ungroupable rows and both handlers bail out if invoked anyway.
… (#7052) * feat(auth): gate activation drip enrolment behind the experiment flag Restores getActivationVariant (removed in #6535) so the activation drip can be A/B tested again, with the analysis change that makes a signup-level split actually readable. Randomising at signup diluted the last run ~3x, because most signups activate before the first nudge ever sends — which is why 387868 would have needed ~80k per arm. But nothing differs between arms until that nudge fires (>=23h after signup), so "not activated at 22h" is a pre-treatment covariate and restricting the analysis to it introduces no selection bias. Measured on exactly that population (insight keu8zq6l, replayed over the Aug 10-17 exposures): 45% activate within 22h and are excluded, and the remainder convert at 16.5% over 14 days. Detecting +2pp against that needs ~5.7k per arm, roughly five months at ~37 eligible per arm per day. Two deliberate changes from the original helper: - Fails OPEN to "test". The old version returned "control" on error, which silently withheld the campaign during a PostHog outage and miscounted those users as control. A failed call also emits no $feature_flag_called, so they land in neither arm rather than polluting one. - A missing, disabled or boolean flag also reads as "test", so this ships as a no-op. Flag 781011 is disabled today; starting the experiment is flipping it on, not deploying. The welcome email stays unconditional in both arms. Gating it in a6beb04 is what invalidated 387868, by moving the control condition mid-flight. Claude-Session: https://claude.ai/code/session_01NNJx4iLBUGs2kVBzso9Wym * fix(auth): bound the flag call and flag the shared-event coupling Two findings from cubic's review of this PR. The flag evaluation is awaited inside the signup hook and posthog-node's requestTimeout defaults to 10s, so a slow PostHog would stall signup rather than fail open. Bounded to 3s. (The SDK option is `requestTimeout`; `featureFlagsRequestTimeoutMs` does not exist in 5.28.8.) The second is real and cannot be fixed in code alone: `user.signed_up` triggers BOTH activation-drip and habit-drip, so withholding it from the control arm withholds habit-drip too. That is safe only while habit-drip is disabled, which it is. Fixing it properly means emitting `user.signed_up` unconditionally, adding an activation-only enrolment event, and repointing activation-drip's trigger in sync-automations.ts -- a Resend automation change, so it is not bundled here. Recorded as a caution at the gate, which is where someone re-arming habit-drip will look. Claude-Session: https://claude.ai/code/session_01NNJx4iLBUGs2kVBzso9Wym
* fix(browser): keep sign-in popups in the pane's session "Sign in with Google" never completed in the in-app browser (SUPER-1272). Two defects on the same path, both required for the flow to work: 1. `setWindowOpenHandler` denied every `window.open` and re-opened the URL as a split pane. A detached pane has no `window.opener` and no window name, so popup OAuth (Firebase `signInWithPopup`, Google Identity Services, Auth0) can never hand its result back. It also denied `about:blank` popups, which is how several auth libraries open the window before navigating it, so `window.open` returned null and tripped `auth/popup-blocked`. Now gated on Chromium's own disposition: `new-window` opens natively, tab dispositions keep the split-pane path. 2. The app-wide `web-contents-created` guard sends any http(s) `will-navigate` in a non-webview `webContents` to the system browser. A popup is a `BrowserWindow`, not a webview, so allowing it in (1) alone got the sign-in URL kicked out to Chrome: the popup sat on `about:blank` while the user signed in in a different browser, landing the cookies in a different jar than the pane that started the flow. Pane popups are now marked in a `WeakSet` the guard consults. Kept as one commit because either half alone leaves the flow broken, so splitting would only produce a knowingly-broken intermediate revision. Geometry is left to Electron, which already parses width/height/x/y out of the `features` string and is outranked by `overrideBrowserWindowOptions`. `partition` is deliberately never set: the popup inherits the opener's session, which is what keeps its cookies in the pane's jar. Verified over CDP against the real app: with the fix, a real click on a "Sign in with Google" button yields a native 500x600 popup with `window.opener` intact and the `postMessage` callback delivered; reverted to the old path, the same click returns null from `window.open` and the OAuth page lands in a split pane with `hasOpener: false`. Claude-Session: https://claude.ai/code/session_01QQV2Jd8VXMYgjRFQSeRNJH * fix(browser): treat bare window.open of a sign-in URL as a popup Testing against a real Deel account found the previous commit does not cover Deel's "Sign in with Google". Deel opens it with a bare `window.open(url)`: no name, no features. Measured in Electron 41, that arrives identically to an ordinary `target="_blank"` link click: window.open(url) -> foreground-tab, frameName '', features '' window.open(url, name, "width=...") -> new-window, frameName 'authpopup' window.open(url, name) -> foreground-tab, frameName 'authwin' <a target="_blank"> -> foreground-tab, frameName '', features '' Rows 1 and 4 are indistinguishable, so disposition alone cannot decide. Gating only on `new-window` sent Google's account chooser to a split pane, where it lost `window.opener` and the callback had nothing to hand the token back to, so Deel bounced to /login. Falls back to the shape of the URL: an OAuth 2.0 authorization request carries `client_id` + `redirect_uri` + `response_type` (RFC 6749 4.1.1). Keyed on those parameters rather than a list of provider hostnames, so it covers any identity provider with no vendor allowlist to maintain. An ordinary `target="_blank"` link has none of them and still opens as a split pane, so that behaviour is unchanged. Verified with a real Google account against app.deel.com: the account chooser now opens as a real popup window, the handshake completes, the popup closes itself, and the pane lands authenticated on Deel. Google raised no "browser or app may not be secure" wall despite the Electron user agent. Claude-Session: https://claude.ai/code/session_01QQV2Jd8VXMYgjRFQSeRNJH * fix(browser): allow blank popups, tighten the OAuth match, stop pinning prefs Review findings on #7049, plus one the reviewer's own research surfaced. 1. `window.open("about:blank")` was denied. Opening a blank window and assigning `location` once the request is ready is how several auth libraries dodge popup blockers, and with no features Chromium reports it as a tab, so it fell through and `window.open` returned null. The caller cannot tell that from a blocked popup. Blank opens are now popups. The `about:blank` special case in `resolveWindowOpen` went with it: it was unreachable once blank URLs are handled above. 2. `isOAuthAuthorizationUrl` only checked that `client_id`, `redirect_uri` and `response_type` were present, so an unrelated URL carrying those names would have been pulled out of the split-pane path. `response_type` is now matched against the values the specs define (RFC 6749 3.1.1 plus the OIDC hybrid combinations), and the URL must be http(s). Deliberately not the suggested remedy of requiring a known authorization endpoint: a provider-hostname allowlist goes stale and misses self-hosted identity providers, and a false negative here means sign-in is silently impossible, which is the bug this PR exists to fix. Constraining the value space removes the false positive without that. 3. `overrideBrowserWindowOptions.webPreferences` is gone. Electron inherits the opener's security preferences and refuses to relax them, so the popup was already no-Node and context-isolated; restating them bought nothing. It also carried a real hazard: if a pinned value ever diverged from the guest's (`sandbox` especially), Electron isolates the child in its own process and `window.opener` comes back null, silently breaking the one thing the popup exists to preserve. Verified in the running app across all four shapes. about:blank-then-navigate, bare window.open of an OAuth URL, and window.open with features each produce one popup window with window.opener intact and the postMessage callback delivered; a plain target="_blank" link still opens as a split pane. Claude-Session: https://claude.ai/code/session_01QQV2Jd8VXMYgjRFQSeRNJH * fix(browser): match the whole about: scheme when deciding a blank popup Swept the popup path for remaining edge cases by driving nine real window.open shapes through the running app. One misbehaved. `window.open("about:blank#state")` matched neither the exact string `about:blank` nor the OAuth shape, so it fell through to the tab path and failed twice over: `window.open` returned null to the caller, and the URL was emitted as a new pane, leaving an empty split behind. Libraries do stash handshake state in that fragment. Matching on the `about:` scheme rather than the bare string fixes both halves, and nothing under `about:` was ever worth a pane. The other eight shapes were already correct and now have the measurements to say so: `window.open()` with no arguments, an OAuth URL opened with `_blank` or with a name but no features, the OIDC hybrid `code id_token` response type, a nested popup opened from inside a popup, `window.close()` from a popup, and `noopener` (which correctly yields a null handle and a popup with no opener). A plain `target="_blank"` link still opens a split pane. Claude-Session: https://claude.ai/code/session_01QQV2Jd8VXMYgjRFQSeRNJH * test(browser): commit the popup CDP smoke test Everything this PR fixes is invisible to unit tests. Opener identity, session inheritance, and whether Electron quietly hands a URL to the system browser only exist in a real Electron window, and each of the three bugs found here got through a green unit suite. Adds `cdp-smoke-popups.ts` alongside the existing cdp-smoke-integrations script: it serves its own fixture, drives thirteen window.open shapes through a live browser pane, and asserts popup count, opener identity, cookie-jar sharing, the returned handle, and pane count for each. The cookie-jar assertion is the one that matters most: the popup reads a cookie the pane set, which is a direct check of the property whose absence was the reported symptom. Two shapes are pinned as expected-negative so a future change cannot quietly flip them: a plain target="_blank" link must still open a split pane, and a `file:` URL must be refused. Run: RENDERER_REMOTE_DEBUG_PORT=9222 bun run dev:desktop, open a browser pane, then bun run apps/desktop/scripts/cdp-smoke-popups.ts Claude-Session: https://claude.ai/code/session_01QQV2Jd8VXMYgjRFQSeRNJH * test(browser): harden popup verification Make the CDP smoke test fail closed on missing popup observations, isolate targets by ID, assert names and cookie sharing, restore pane state, and clean up through real UI shortcuts. Tighten OAuth response-type validation to exact standardized combinations. * test(browser): make popup smoke cleanup fail-safe Always discover and close targets created by a case from its finally block, even when observation or assertion setup throws. Guard pane restoration too so CDP connection failures are reported without leaking the remaining sockets. * test(browser): avoid stale pane cleanup Refresh recorded targets after successful enumeration and re-count live case-created panes before every close. This preserves cleanup fallback data without allowing stale target records to close a pre-existing pane. * test(browser): tolerate slow pane cleanup Poll for a bounded five-second window after dispatching the pane-close shortcut. This avoids a second close while the renderer is still updating, while keeping a genuinely stuck case fail-closed. * test(browser): verify callbacks and target pane cleanup Assert each sign-in case's exact opener callback instead of relying on an aggregate count. Focus each test-created pane through its host-rendered header before closing it, and verify all current target deltas so recreated webviews cannot escape cleanup. * test(browser): scope split-pane cleanup Close each test-created pane through its own header action instead of the global active-pane shortcut. A missed target can no longer close a pane that existed before the smoke test began.
* chore(email): drop habit-drip so nothing can re-arm it habit-drip has been disabled since the 2026-08-21 flood and has never sent a single intended email -- its only production run was the incident. Leaving its definition in `desired` kept it one `--apply --force` away from returning, and it now returns *wrong*: #7052 gates `user.signed_up` for the activation A/B's control arm, and habit-drip triggers on that same event, so re-arming it would silently drop control users out of the habit campaign. Removing it from the script is the fix that actually holds. Deleting the live Resend automation instead would have made things worse: the create guard keys off a same-named DISABLED automation existing, so deleting it would let the next plain `--apply` create habit-drip enabled, with no `--force` required. The stub stays where it is, now inert rather than load-bearing. `user.signed_up` therefore has exactly one consumer, which is what makes the signup-hook gate safe. Both the script header and the gate comment now say so, since the constraint is on whoever adds the next trigger. Recoverable if we want it back: definition in git, both templates still published in Resend. Dry-run after the change: `activation-drip: up to date`, no throw, exit 0. Claude-Session: https://claude.ai/code/session_01NNJx4iLBUGs2kVBzso9Wym * chore(email): delete the habit-drip email sources The two components only ever fed habit-drip, which the previous commit removed from the sync script. Nothing imports them -- they are standalone react-email sources picked up by `email dev`/`email export` from the directory, so removing the automation left them dead. 02-parallel.tsx -> habit-01-parallel "The day it clicks" 04-automations.tsx -> habit-02-automations "Put a chore on a schedule" 02-parallel is the one that went out 1,487 times on 2026-08-21. The published Resend templates (cdf4c8a5..., 49cd4196...) are left in place. Nothing references them now that habit-drip is gone from `desired`, and there is no template-publish script -- they were uploaded by hand -- so deleting them is a separate manual call. Claude-Session: https://claude.ai/code/session_01NNJx4iLBUGs2kVBzso9Wym * chore: address review on the habit-drip removal Rule count in the script header was left at "Three" after adding a fourth. The gate comment claimed activation-drip is "the sole automation triggering on user.signed_up", which overstates what the repo can know: sync-automations.ts is create-only and the Resend account can hold automations it never defined. Scoped the claim to the versioned definitions and said to check the live account before trusting it. Note the reviewer's specific worry does not apply: the disabled habit-drip stub left in Resend triggers on `user.activated`, not `user.signed_up` (the 2026-08-21 containment PATCH replaced its graph), so re-enabling it would not recreate the second-consumer hazard. Verified against the live automation. Claude-Session: https://claude.ai/code/session_01NNJx4iLBUGs2kVBzso9Wym
* fix(usercontent): give page assets a policy of their own `serveAsset` branched only on `text/html`. Everything else was returned with no Content-Security-Policy and no disposition, so an SVG published alongside a page executed as a top-level document with no policy at all. Measured against production: script ran, and `fetch` and `sendBeacon` both reached an external host, a GET and a POST with a body — exactly the network access the page's own CSP exists to deny, and which the page skill promises is impossible. Non-document assets now carry the sandbox policy, and an SVG renders only when it was asked for as an image, downloading when navigated to. The media route's `fileResponsePolicy` was not reusable directly: its inline set excludes text/css and text/javascript, which a page legitimately serves as subresources, so this is a sibling policy rather than a shared one. Claude's artifacts cannot have this shape of bug — an artifact is a single inlined file with no separate asset origin to serve a document from. Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf * fix(usercontent): vary SVG responses on Sec-Fetch-Dest An SVG's disposition is chosen from `Sec-Fetch-Dest` — inline for an <img>, attachment when navigated to — but the response was cached with no `Vary`. Browsers share one HTTP cache across navigation and subresource requests, so whichever variant was fetched first would be reused for the other: an <img> load first, and a later direct navigation renders the SVG inline instead of downloading it, undoing the guard through the cache alone. The policy now reports whether it consulted the fetch dest, and both routes send `Vary: Sec-Fetch-Dest` when it did. The media route has varied SVG this way since it was written and had the same latent hazard, so it is fixed here too. Found by review on #7059. Claude-Session: https://claude.ai/code/session_01QAQLVm3xTUXsJhpPQo3Nuf
|
Important Review skippedToo many files! This PR contains 2135 files, which is 2035 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: ⛔ Files ignored due to path filters (13)
📒 Files selected for processing (2135)
You can disable this status message by setting the |
The job mints a GitHub App token from GH_APP_ID/GH_APP_PRIVATE_KEY, which the private mirror does not have, so it failed on every PR here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hudDfZfDJGdEb7jm3n26B
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Upstream range merged
fbaf7888c..cb3268fdf— 88 commits fromsuperset-sh/supersetmain, dated 2026-08-28 → 2026-08-31.Merge commit:
git merge --no-edit upstream/mainon top ofmain. No conflicts.Highlights
Desktop
MIN_HOST_SERVICE_VERSIONraised to 1.21.0 (#6998).Host service / CLI / agents
Web / API
/downloadpage (#7047),/securitypage (#7026).Mobile
Intl.PluralRulespolyfill for Hermes (#7040); Bluetooth route-change crash in dictation fixed (#7015).CI
translate-catalogs.ymlworkflow (#6984).Docs / deps
readme/(#6997); weekly changelog 2026-08-30 (#7022).Conflicts
None. The merge applied cleanly.
Mirror-specific workflow changes verified present after the merge (diffed against
upstream/main): theci.ymltypecheck concurrency cap +NODE_OPTIONSheap bump, andif: github.repository == 'superset-sh/superset'gating ondeploy-preview.yml,deploy-production.yml,deploy-relay.yml,cleanup-preview.yml,bump-homebrew.yml,triage-issue.yml.Note: upstream added
translate-catalogs.ymland new jobs indeploy-preview.yml/deploy-production.yml. The gating on existing jobs carried over; a reviewer may want to confirm the new upstream jobs don't need the samegithub.repositorygate on this mirror.Local checks
Run on macOS after
bun install(lockfile unchanged). Tests were run with the pinned bun 1.3.14 (.bun-version).bun run typecheck--concurrency=2,NODE_OPTIONS=--max-old-space-size=8192)bun run lintbun run testTest notes (environment, not upstream regressions):
PATHfailed 10 tests in@superset/desktop(terminal-ws-transport.test.ts) and@superset/host-service(git-watcher.test.ts) withTypeError: jest.advanceTimersByTime is not a function. Rerunning with the pinned bun 1.3.14 made all of those pass.@superset/host-servicetests (agents.test.ts›buildTerminalAgentLaunch) failed only because this machine exportsCLAUDE_CONFIG_DIR, which the launch builder splices into the command string:CLAUDE_CONFIG_DIR/SUPERSET_DEFAULT_CLAUDE_CONFIG_DIRunset: 49 pass, 0 fail. Everything else in the suite (all 21 turbo tasks) passed.Stale sync PRs
None open.