Theme CSS had no supported way to style a chat switch: t3-code-theme's
installSwitchFade watched the focus store, polled transcript rows and
scroll position in a rAF loop and toggled its own root attribute — DOM
reads catalog rule 8 now refuses.
Core already knows both phases of a switch, so it publishes them: the
chat surface root ([data-chat-surface], per pane) carries
data-session-switching="true" while a routed session is loading
(Thread loading === 'session') and while the thread list's scroll
restore places the new rows, removed on settle, user input, failed
resume or unmount. Each phase holds a token; the attribute goes when the
last holder releases, so the load ending never clears the restore's
mark mid-switch. An attribute (not a view-transition or motion token)
because the need is "know when", the duration is core's real settle
time, and CSS composes it with any theme without JS.
Plugins could relabel the model pill (COMPOSER_AREAS.modelPill) but the
menu it opens was core-only, so a theme wanting provider marks next to
each model (t3-code-theme, catalog #129600) had to MutationObserve the
open menu and paint into its rows, which catalog rule 8 now refuses.
A `modelMenu.row` data contribution supplies decorate({ provider, model,
label }) -> { icon?, badge? } | null. ModelFamilyRow paints the answer in
two fixed slots it owns: a leading 1rem icon box before the name and a
trailing chip after core's own chips. Per slot the first usable answer in
registry order wins, so an icon plugin and a badge plugin compose; a
throwing decorator or a non-element/non-string value declines, and the
icon renders inside its own error boundary so a crashing plugin component
blanks only its slot, never the menu. Applies to every ModelCatalogMenu
host (composer pill, kanban override), since a mark belongs to the model.
`new URL("//", origin)` threw inside the http request handler, so a
request like `GET //` escaped as an uncaught main-process TypeError and
was never answered. Reject unparseable targets up front.
A Desktop that ran the reverted #130015 build has the CLI `default` skin
cached under `hermes-desktop-backend-themes-v1`, labelled "Classic Hermes".
readCached only dropped built-in names, so that entry survived next to the
new `classic` theme: two Classic cards, the stale one previewing gold and
applying Nous, and two Classic rows in /skin list. readCached now also drops
every retired name (`default`, `gold`, `nous-light`, moved to presets.ts as
RETIRED_SKINS so the store and the theme provider share it) and writes the
cleaned map back, so the entry is gone from disk on the first launch. The
same rule keeps ingestBackendSkin from registering a theme under a retired
name.
Classic also ignored light mode: it took the single-mode converter output,
dark palette in both slots. Its light variant now comes from the CLI
default skin's `light_colors` ladder (#F5F5F5 surface, goldenrod accent)
with a deeper ink of the same brown, so Desktop's 74% / 54% secondary and
tertiary text land at Nous Light's contrast (6.5:1 / 3.5:1; body 12.3:1).
Dark is unchanged. The description now reads "Gold on navy, the CLI's
original look" and the `as DesktopTheme` cast is gone.
Desktop users can now choose the CLI's classic gold-on-navy look in
Settings > Appearance (and via /skin classic, gold or hermes).
It ships as a Desktop built-in under its own id `classic`, converted from
the CLI `default` skin colors through the same skinToDesktopTheme path a
backend skin takes. It is deliberately NOT registered under `default`:
stock config ships `display.skin: default`, and backend sync / the boot
fallback adopt that value, so un-retiring `default` (the reverted #130015)
repainted every user who never picked a theme in Classic gold and cached
it. `default` stays retired and aliased to Nous; Classic is only painted
when someone picks it. Desktop never writes its pick to display.skin, so
the pick stays Desktop-local like every other Desktop theme.
Re-lands #130015 (reverted in #130852).
Co-authored-by: 686f6c61 <github@00b.tech>
Call renderActionItem instead of declaring an Item component inside
shellSections, which minted a new component type on every render and
remounted the menu rows. Restore the tree-group file doc comment above
its imports.
Keep explicit nested menus and bare pane operations while restoring text, editable, link, and image menus. Reuse the app actions in a fallback section of the pane-body menu.
YouTube shows error 153 unless the page embedding its player has an http(s)
origin matching the player's `origin` param; the packaged renderer is file://
and Electron drops stamped Referer headers. Serve one wrapper page per video
from a lazily started 127.0.0.1 host in the main process and point the
file:// renderer's YouTube iframe at it. The renderer itself stays on file://,
so settings and backend connections don't move. Dev/web renderers, which
already have an http origin, keep embedding directly, and so does a renderer
bundle running on a main process without the host.
npm runs the `postbuild` hook after every `npm run build`, and the desktop
compiler (scripts/build/desktop.mjs) already runs checkDistBuilt on the
scratch product before it publishes dist/. Every update and every pack
therefore re-verified the bytes it had just verified; with the per-chunk
syntax check that was a second ~960-process pass (#123216).
Drop the hook; nix's checkPhase calls the guard directly.
`assert-dist-built.mjs` validated the emitted renderer chunks by spawning
`node --input-type=module --check` once per chunk. A real bundle here emits
~960 `.js` chunks (31 MB), so the postbuild step performed ~960 sequential
process creations: 47-80 s wall clock measured on an idle machine (median
77 ms per spawn, worst 446 ms). That made the update's build step depend on
~960 successful process creations.
On Windows one spawn stalled past its 60 s budget and returned ETIMEDOUT
(`could not run node to syntax-check nord-D92QzZWU.js`), which failed
`npm run build` and aborted the entire update (receipt outcome "failed",
exit 1) even though vite had reported `built in 54.73s`. Nothing was wrong
with the bundle or with node - a single process creation was slow.
Parse every chunk in one child instead: `scripts/check-chunks-parse.mjs`
uses `vm.SourceTextModule` - the in-process module parser, hence
`--experimental-vm-modules` - and writes a JSON verdict on stdout. It
reports the same early errors as `node --check`: invalid destructuring
patterns and duplicate declarations still fail, so the guard keeps catching
the corrupted-silent-fail bundle it was written for. Same verification now
costs one process creation and ~1.5 s instead of ~960 spawns and 47-80 s.
The verdict carries `kind`, and the guard reports `harness` when the check
could not run versus `bundle` when the build is defective. The line is drawn
at the checker: only a SyntaxError is a bundle defect; an unreadable chunk or
an unavailable vm API is a failure of the check itself. Previously every
failure printed "The renderer bundle is missing or incomplete ... Re-run the
build", so a blocked or stalled node process was reported as a bundle defect
and sent at least one investigation after a problem that did not exist.
The social renderer keeps its measured height in state, so React reusing
the instance for a different X/Instagram descriptor carried the previous
post's height over; keying it on descriptor.id gives each post a fresh
frame and height.
Also tighten the module: SOCIAL_FRAME_SANDBOX, socialFrameSrc and
reportedHeight have no outside users, so they are no longer exported;
FRAME_ORIGIN is typed by EmbedProvider so a misspelled key fails to
compile; and the 320px fallback duplicated across social-embed,
embed-consent and url-embed now lives once in embed-size.ts as
EMBED_DEFAULT_H.
The origin/source rejection test only asserted that the height stayed at
450px, which also passes if the message listener is never attached. A
valid MEASURE from the frame's own origin and window must now resize it
to 999px, so the test goes red when the listener is removed.
Keep the two tests that guard the security boundary: X/Instagram render in
a sandboxed cross-origin iframe with no script injected into the app
document, and height messages from a foreign origin or window are ignored.
Theme/resize-shape/parser cases pin vendor message details rather than the
fix.
The tweet and Instagram renderers injected the vendors' widgets.js/embed.js
into the app document, which also hosts the preload bridge
(window.hermesDesktop). A compromised or hostile vendor script would run with
full access to that bridge. Render both in a sandboxed cross-origin iframe
instead and size it from the embed page's own height messages, accepting
them only from the vendor origin and the frame's own window.
Hand rebuild of #129637 onto current main: keeps the LazyRenderer autoplay
signature, deletes the now-unused escape-html.ts.
Salvage of #129637.
The out-of-scope strip matched any request header whose value the session
had ever injected, so an unrelated request that happened to carry the same
name+value (e.g. an identity provider using the same API key header) had it
silently removed, and the value set grew without bound for the session's
lifetime.
Track the header names injected per webRequest id instead: an out-of-scope
hop of that same request drops them, every other request is untouched.
Entries are forgotten on webRequest.onCompleted/onErrorOccurred so the map
stays bounded by in-flight requests.
Also: the tracking map is now a required parameter (no silent per-call
default that disables stripping), requests with nothing to add and nothing
tracked take a callback({}) fast path, redundant String() coercions are
gone, and the configured headers are walked once. The test swaps the
production-forbidden Authorization header for X-Api-Key and pins that an
unrelated request with the same name and value is not stripped, including
after the original request id completes.
Chromium carries app-set request headers (Authorization, CF-Access-*) across
redirects, so a gateway that 30x-redirects to another origin or a path outside
the connection's scope leaked those credentials to the redirect target.
applyRemoteRequestHeaders now remembers every configured header value the
session injected and removes it from hops that are outside its gateway scope,
re-applying only the headers resolved for the current URL. Unrelated requests
that happen to use the same header name with a different value are untouched.
Hand rebuild of #129625 onto current main: keeps the withEmbedRefererStamp
wrapper on the single onBeforeSendHeaders listener, drops the live Electron
fixture/test.
Salvage of #129625.
projects.test.ts carried a file-local deferred() that only exposed
resolve, so the missing-RPC test hand-rolled its own captured-reject
promise. Import the shared @/test/deferred helper (resolve + reject)
instead, drop the duplicate, and settle the stale-owner projects.create
rejection through pendingCreate.reject. No behaviour change; the seven
existing deferred callers work unchanged.
activeProjectsContext inlined the same gateway/profile comparison as
stillOnWritableProjectOwner; reuse the helper so the writable-owner rule
lives in one place (behaviour-equivalent).
fileEditScopeKey mixed a render snapshot (beginEdit passed fsCacheKey)
with a live API-route read; read both halves live so begin and save
compare like with like. The save-scope test now asserts the exact scope
error instead of accepting "edit mode exited" as an alternative.
The catch branch only latches $projectsRpcAvailable=false when the
rejection still belongs to the current gateway/profile, but nothing
exercised it (forcing the guard to `if (true)` kept everything green).
Extend the existing missing-method test with an owner-switch case: a
late missing-method rejection after a profile switch still surfaces the
stale-backend error but must not mark the new owner's backend stale.
Also trim the owner-switch createProject test: the writer is asserted
never to be called, so the temp IDEA.md files and routing mock were dead.
The own-residue delegate test only checked the seeded transcript had two
messages, which would also pass for any two wrong rows; assert the exact
server row ids [1, 2] instead (drops the redundant objectContaining line).
The stale-guard-loop header still said both submit paths refuse on any
non-null refresh; they now refuse only a competingView surplus and graft
own residue then send, so say that.
Its doc opened "Same read, plus …", pointing at refreshIfTranscriptStale,
which the previous commit deleted. That deletion also dropped the only
statement of the null contract (null when current or when the read fails,
so a missing profile or down backend cannot soft-lock send). Restate both
here so the surviving export documents itself.
Its last caller (the tile delegate) now uses transcriptRefreshIfBehind,
which distinguishes own residue from a competing view. Keeping the
residue-blind variant exported invites the same #130031 regression.
Quick Entry / tile submitToSession still used refreshIfTranscriptStale,
which refuses on ANY surplus in the authoritative transcript - including
this window's own server-side turn residue (a turn that died on an
approval timeout). The composer path (submit.ts) already moved to
transcriptRefreshIfBehind in #124005; the tile delegate did not, so a
Quick Entry follow-up was refused forever with a stale-session warning.
Use transcriptRefreshIfBehind: a competing view still refuses with the
unprovable {runtimeSessionId, storedSessionId: null} identity, while own
residue is grafted into the cached state and prompt.submit proceeds.
Fixes#130031. Hand rebuild of #130035 (its merge-base predates the
refusal identity return, so a cherry-pick would revert it).
Public http:// and LAN hosts navigate; data:, javascript:, file: and garbage
still return null. The matrix kvnloo asked for on #129758, now that the
host allowlist is gone and the protocol check is the only gate.