The inline branch of MATH_SPAN_SPLIT_RE excluded `$` from the body outright,
so a `\$` inside inline math — a literal dollar sign, valid TeX — broke the
span match and the shield silently didn't apply. `$\sqrt[3]{8} + \$5$` still
lost its index.
Step over escape pairs instead, matching the escaped-delimiter rule
findClosingSingleDollar already applies via isEscapedAt. The two body
alternatives are disjoint on their first character, so the added quantifier
can't backtrack ambiguously.
Reported by Copilot in review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
`$\sqrt[3]{8}$` renders as a plain square root — the index is gone. It is
not a KaTeX layout problem: the index never reaches KaTeX. CITATION_MARKER_RE
strips `[3]` as a citation marker, because its lookbehind accepts any letter
and the `t` of `\sqrt` qualifies. That runs inside normalizeVisibleProse,
which splits out inline code spans but not math, so TeX is fed to rewrites
written for prose.
Numeric-only, which is why `\sqrt[n]{8}` survives and made this look like a
layout edge case rather than a preprocessing one.
Shield math the same way inline code is already shielded: split each prose
part on math spans and rewrite only the segments between them. That also
takes math out of the reach of the other rewrites in that pass
(autoLinkRawUrls, LOCAL_PREVIEW_URL_RE, the ``` stripper, linkifySessionRefs),
any of which can corrupt TeX the same way with different input.
The split is capturing, and math segments are identified by index parity
rather than a leading `$`, so a prose run that merely opens with a stray
dollar cannot be mistaken for math. Escaped `\$` delimiters stay prose, which
is what keeps `$5 and $10` escaping intact.
Verified in the real renderer through the desktop mock-backend E2E harness:
`.katex .root` (the span KaTeX emits for a radical index) goes from 1 to 4 on
the same four-radical reply, and the MathML annotations show KaTeX receiving
`\sqrt[3]{8}` intact rather than `\sqrt{8}`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
Multi-line display math whose $$ delimiters hug the body
(e.g. $$\begin{aligned}...\end{aligned}$$) renders as raw error text.
remark-math's flow-math construct is fence-shaped: text after the
opening $$ on the same line is read as an info string and discarded,
and the closing $$ is only recognized alone on its own line. So the
block never closes and KaTeX paints the remains via its error fallback.
splitHuggingDisplayMath moves those delimiters onto their own lines. It
runs AFTER normalizeMathDelimiters because that rewrite is itself a
source of the hugging form: a multi-line \[...\] comes out of it as
$$\begin{aligned}...\end{aligned}$$, so the same bug reached users who
never typed a $$ at all.
Single-line $$...$$ is left alone (it routes through the inline
math-text construct and already renders), container prefixes are
replayed onto the delimiter lines, and both patterns anchor $$ to the
start of the line, which keeps them from firing inside an inline code
span.
Verified end to end: the repro emits katex-error through
remark-math + rehype-katex before this change and not after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assistant messages that link a file the agent wrote —
[report](/home/user/report.md), file://…, ~/…, C:\… — rendered as dead
anchors: file:// is blocked in the renderer, Streamdown's URL hardening
turns file:/~/ hrefs into "[blocked]" spans, and on a remote gateway the
path isn't on the viewer's disk at all. Issue #82140 proposed exposing
the Desktop connection mode to skills/MCP/plugins so EXTENSIONS could
emit different output per viewer; this fixes the symptom at the right
layer instead — the viewer surface resolves paths at VIEW time, so
extension output stays surface-agnostic and the same transcript works
from every machine that opens it.
- markdown-preprocess: routeFileLinksToPreview() rewrites filesystem-path
links in prose to the renderer's existing hash-href doors —
#preview/… (PreviewAttachment) for documents, #media:… for
audio/video/image extensions. These pass URL hardening by design and
resolve through normalizeOrLocalPreviewTarget / resolveMedia*Src:
local connections read the file directly, remote connections fetch
over the authenticated /api/fs bridge. Image syntax, fences, inline
code, anchors, relative and http(s) links untouched.
- markdown-text: MarkdownLink routes any filesystem href that still
reaches it (bypassing preprocess) to PreviewAttachment/MediaAttachment
instead of a bare dead <a>.
- media.ts: export isFileMediaPath.
Live E2E (built app, CDP-driven, fixture session with links to a real
gateway-side file):
- BEFORE: [report.md] = dead <a href="/home/…"> (click: nothing),
[notes](file://…) = "notes [blocked]" span, 0 preview affordances.
- AFTER: both render as attachment rows; Open preview shows the file's
content in the preview pane; zero blocked spans; screenshots verified.
Closes#82140. Supersedes PR #82187 (connection-mode API): with view-time
resolution the extension layer no longer needs to know where the viewer
sits.
A `.md` delivered via MEDIA has no entry in MEDIA_BY_EXT, so mediaKind()
classified it as a generic 'file' and MarkdownLink rendered a download-style
anchor. Markdown is renderable content: route markdown document paths to
PreviewAttachment (source='tool-result'), which opens them in the right-rail
preview pane — where .md already renders with a rendered/source toggle and,
since #89381, full KaTeX math, tables, images, and links.
Resolves#84951 (the MEDIA delivery half; the rail-side rendering half landed
in #89381).
The Bots pane docks into the sessions zone but did not declare
collapsible. Below the sidebar-collapse breakpoint, the sessions pane
left the grid and the zone kept a stranded BOTS tab on screen.
The Bots pane now declares collapsible, so it leaves the grid with
its zone. The narrow edge overlay now mirrors the zone's tab strip
when the revealed pane has collapsed zone-mates. Without the strip,
only the first pane of the zone was reachable while collapsed. A
lone pane keeps the stripless overlay form.
Each closeable horizontal tab now shows a close button when the
pointer is on the tab. A small gradient fades the button into the
tab surface, so long labels fade under it instead of a hard clip.
The gradient reads the tab's effective surface color. This color
tracks the hover and selection washes, so the fade is correct on
every theme.
Middle-click and Cmd-click still close the tab. Vertical rail tabs
keep those gestures and do not get the button.
The scoped cmdk bar on Tools & Keys was a second search UI for the same
catalog ⌘K now serves. Cards stay listed; deep links still expand, scroll,
and flash the matching credential.
* feat(desktop): add scoped credential command search
* feat(desktop): add global settings search
* feat(desktop): settings search lives in the command palette, deep everywhere
Replace the PR's sidebar search input with a settings page inside the ⌘K
palette (the subPages mechanism themes/pets already use). ⌘K on the
Settings overlay opens the palette scoped to settings; Back/Esc/Backspace
step out to the root. The deep catalog — schema-driven config fields,
appearance controls, stored credentials — now also serves the ROOT
palette's typed results, replacing the static section-key list, so ⌘K is
the same settings search everywhere. The input refocuses on page swaps,
and a seed store carries the keystroke that opened the palette so
type-to-search never loses the first character.
* feat(desktop): search pill rides the Settings card's top edge and hands off to ⌘K
The trigger is a fake pill — pure chrome, not an input — centered on the
overlay card's top border, half off the card (a new OverlayView edgeBadge
slot; a sibling of the card since the card clips its own corners). Click
or just start typing to open the scoped palette; while the palette is up
the pill scales up and fades out, then fades back when it closes.
Type-to-search forwards printable keys (outside editable targets) with
the keystroke seeded into the palette filter.
* feat(desktop): plugins are searchable — ⌘K 'kanban' lands on Settings → Plugins with the row flashed
The catalog stopped at config fields and credentials; installed plugins
were invisible to search. Both stores now feed it — desktop plugins from
$pluginRecords, agent plugins via the same plugins.manage RPC the Plugins
page fires (deduped by the store's inflight guard, filtered by the shared
desktop-relevance curation, which moves to the store so both callers use
one predicate). Rows deep-link as ?tab=plugins&plugin=<id|key> and the
page scroll-flashes the row via the shared useDeepLinkHighlight hook.
* style(desktop): satisfy perfectionist import/prop ordering
* test(desktop): add setApiRequestProfile to KeysSettings hermes mock
---------
Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
Co-authored-by: Adolanium <adolanium@users.noreply.github.com>
Clicking a link in the transcript, a url chip, or the terminal left
Hermes entirely. Now a web page opens in the browser pane — no context
switch to read a doc, and it's the surface the agent can see — while
Cmd/Ctrl-click and middle-click still escape to the real browser.
Routing lives in one helper so every link surface agrees. Anything that
isn't a web page (mailto:, file:, custom schemes) always hands off to
the OS, as do billing, OAuth, and local media, which need a real
logged-in browser.
The terminal keeps its own chord: it already spends Cmd/Ctrl on
activating the link at all, so Shift is what escapes there.
The Browser tab could only show whatever opened it: no back, no forward,
no way to type an address. Adds the row every browser has — back /
forward / reload / address — above the page.
The console and DevTools toggles move here from the zone strip. They act
on one page, so they belong beside its address rather than on a strip
shared with every other tab; that also frees the space the address bar
needed. Nothing else contributed strip tools, so the extension point
goes with them.
Glass thins the field tokens, so anything that needs a fill for a reason of
its own had to be exempted. Those exemptions were written as styles.css
reaching into other components by class name and slot -- .cursor-grabbing,
.composer-human-message-container, [data-slot='file-diff-panel'] -- which
puts the knowledge in the wrong file: rename the class, lose the fill, and
nothing fails until someone turns glass on over a diff.
There are only two roles. A surface that MASKS its siblings (the diff
gutter, a dragged row) must stay opaque or it reads as text through text. A
surface RAISED above the field (overlay cards, the inline edit box) stays
near-opaque and never thinner than the field behind it. Each one now says
which it is at its own call site, and styles.css styles the roles.
This also narrows the diff-panel rule to the sticky gutter that actually
masks, rather than the whole panel.
Community reports (Aug 2026): sidebars where sessions and the Bots pane were
already co-located in one group, but the group's tab strip was hidden
(headerHidden) with Bots holding the active tab — Sessions existed yet was
invisible with no strip to switch back ("my ui only shows bots now on the
side, cant find the sessions").
The enforce pass only re-homed panes in a DIFFERENT group than their anchor;
the co-located-but-hidden shape hit the early continue and was never
repaired (insertAtGroup's headerHidden:false pin only runs when an insert
happens). enforceDockedPanes now forces the anchor group's strip visible
when the enforced pane is already co-located — the active tab is not
stolen; the strip is simply reachable again.
New regression test reproduces the community shape (co-located +
headerHidden + bots active) and asserts the strip is forced visible;
sabotage-verified (reverting the repair fails exactly this test, 1F/5P).
The one-time dock heal from #88788 ('sessions-tab-v1') burned its token even
when its guards skipped the move, and exempted $userPlacedPanes — so exactly
the users who had fought the old stacked layout by dragging panes stayed
stacked forever (empirically reproduced on a live desktop: burned-token and
user-placed installs both keep the Bots pane split below Sessions on current
main). Replace the heal with an enforced dock invariant (dock.enforce: true):
the Bots pane re-homes into the sessions zone's tab strip at every boot's
first adoption pass, idempotent per boot so an intra-session drag sticks
until the next launch. The retired paneDockHeals.v1 ledger is dropped on
store load.
- Bot Mode's pane dock changes to { pane: 'sessions', pos: 'center' }: the
lone-pane header auto-hide trap the old 'bottom' split worked around is
fixed (center gains pin the header shown), so the sidebar grows a
SESSIONS | BOTS tab strip instead of two cramped stacked panes.
- One-time persisted-layout heal (dock heal tokens in the tree store):
installs that adopted under the old split re-home into the sessions strip
exactly once; user-placed panes are never touched and the burned token
never re-fights a user who re-stacks afterward.
- The profile-scoped Cronjobs (routines) pane now registers only while the
Bots pane is on screen, via the contribution disposer driven by the new
host.paneVisibility SDK export (reactive $paneVisible). Feature-detected
in plugin.js with the always-registered fallback for older desktops;
the visibility listener tears down through ctx.onDispose.
An inline ::preview widget could render and be clicked, but the click went
nowhere: the sandbox has no channel to the agent, so an interactive chart
was a dead end. Now the frame injects a second script beside the measurer
that gives the page one voice:
window.hermes.send('get-price eth')
<button data-hermes-send="get-price eth">ETH</button> (zero-script form)
The prompt rides postMessage up tagged with the mount token, then goes
through the composer's own send path (requestComposerSubmit -> prompt.submit)
flagged display_kind=hidden — the same row-typing auto-continue and internal
notifications already use. The agent wakes and takes a real turn; the
durable row persists (context, resume, DB audit); but NO bubble renders,
live or on reload. The user clicks ETH and the chart just changes — the
off-screen loop is click -> hidden turn -> agent rewrites the widget file ->
frame hot-swaps.
Trust boundary matches size reports and is tighter where it matters: mount
token required (frames can't forge each other's intents), string-only,
trimmed, capped at 500 chars, throttled to one intent per second per frame.
The gateway whitelists display_kind to "hidden" — the RPC can't mint
arbitrary row types — and the flag threads through both turn paths (inline
and compute-host isolation) so isolated sessions don't resurrect bubbles on
resume.
The desktop platform hint teaches the model to wire interactive widgets
with data-hermes-send and to answer clicks by updating the widget's file
rather than with prose; the SDK doc documents the contract.
CI's check:lint caught three real issues in the directive surface:
- TranscriptDirectiveLeaf called the contribution's render() inline in JSX
— the exact pattern no-restricted-syntax bans because the callback's hooks
land in the host and a plugin reload changes the host hook count
(React #310). The callback is now memoized and mounted via ContribRender.
- The inline frame mirrored its height state into a ref from the message
handler (the stale-read pattern no-restricted-syntax flags). Functional
setState reads current state directly; the shadow refs are gone.
- perfectionist import/export ordering in the frame and the SDK index.
The first inline frame was a full-width bordered box at a fixed height:
webpage-in-a-rectangle, not a widget. Now the frame disappears into the
message flow:
- Content-driven size. The injected measurer reports height (live) and
intrinsic width (adopted once, so %-width children can't feedback-loop the
frame toward zero). A sparkline shrink-wraps and sits flush left like an
inline image; a full-bleed page measures the whole viewport and stays
column-wide. The height attribute is now only a starting value.
- Theme bridge. A style prelude injects first with the app's resolved theme
tokens under stable names (--foreground, --muted-foreground, --accent,
--border, --card), the app font, zero body margin/padding, and a
transparent background — reference HTML written against those vars renders
native in any theme. Page styles override the prelude, so a page that
brings its own design keeps it.
- No chrome. Border, rounded box, and the rail-opener card under the frame
are gone; the fallback paths (non-HTML, remote gateway, unreadable file)
keep the classic card. The wheel gate went with the border — frames size
to content, so there is nothing to scroll inside, and widgets are fully
interactive.
- The desktop platform hint now teaches the default: an inline widget is
transparent, token-colored, flush left, no page chrome — only a standalone
page brings its own background. "Make me an inline sparkline" gets native
styling without the user spelling it out.
The frame was a hardcoded 280px unless the model guessed a height attribute
— tall pages clipped (the flip-clock demo cut its last digit), short ones
floated in dead space. The opaque-origin sandbox means the parent can't
measure the document, but we own the srcdoc string: a tiny injected script
observes the document with ResizeObserver and posts its scrollHeight up via
postMessage, and the frame tracks it live within the 120-1200 clamp.
Reports are validated before they can move layout — per-mount random token
(two previews in one transcript, or a hostile page inventing messages,
can't move each other's frames), finite-number check, clamp. A 4px
tolerance stops vh-sized pages (which measure exactly what they're given)
from oscillating; an explicit height attribute still opts out of
auto-sizing entirely.
The first cut of the core ::preview consumer rendered the classic
preview-attachment card — a button into the right rail we already had, which
made the directive indistinguishable from an ordinary preview link. Now the
directive shows the thing itself: the workspace HTML file renders in a
sandboxed srcdoc iframe inline in the assistant message (opaque origin,
allow-scripts only — no reach into the app, its storage, or the bridge),
with an optional height attribute clamped to 120-1200px and the classic
card kept below as the rail escape hatch.
The frame waits for turn settle before reading the file (mid-stream it is
often mid-write), resolves relative paths against the session's own cwd,
and falls back to the plain card for non-HTML targets and remote gateways
(no local file door there).
The transcript becomes a contribution area (transcript.directives). A plugin
registers a named directive and the model addresses it by emitting
::name{key="value"} as its own paragraph; that leaf renders as the plugin's
component, wrapped in the contribution error boundary. Unclaimed or malformed
directives stay plain prose, so nothing changes for text that merely looks
like a directive (std::vector) or for users with the plugin disabled.
Core ships ::preview{file="..."} as the reference consumer (the existing
preview-attachment card), the desktop platform hint teaches the model the
syntax, and the SDK exports the area + types so runtime plugin.js files get
the surface through the normal plugins API.
Desktop shipped the same bug class four times in one week: a decorative
animation loop that never sleeps (bots face clock #88543, pixel egg #88406,
diffusion placeholder #88564, plus the #77651 hidden-renderer wave). Each fix
hand-rolled the same four behaviors. This extracts them into one helper in
src/lib/budgeted-loop.ts:
- fps budget (default 15) on top of rAF
- observability pause via the existing createRendererLoopPauseController
- idle dormancy: idleWhen() true after a draw parks the loop with zero
pending work until wake() — the piece every hand-rolled loop forgot
- teardown: dispose() cancels the frame, disposes the controller, and makes
wake() a no-op
DiffusionCanvas migrates onto it as the first consumer (net -40 lines at the
call site); its existing scheduling/budget/instance-cap tests pass unchanged
against the migrated implementation. Helper suite covers budget, pause,
park/wake, dormancy-survives-focus-churn, and dispose idempotency; sabotage
run (budget+dormancy stripped) fails 4/5.
Extends the scheduling test suite from #88407 with behavioral coverage for
the salvaged #79327 work: frames inside the 1000/15 budget reschedule
without repainting, instances over MAX_ANIMATED_INSTANCES draw one static
frame and skip the loop, and the counter releases on unmount. Both tests
verified to fail against the pause-controller-only version.
- Throttle frame rate to ~15fps via timestamp check instead of
redrawing on every requestAnimationFrame callback
- Pause animation when document.hidden, resume on visibilitychange
- Cap concurrent animated instances at 2; extras render a single
static frame instead of starting another animation loop
- Fixes#79077
Each assistant reply now carries a small time badge below the message text
showing how long its turn took (message.start -> message.complete), so
users can gauge task latency at a glance without hovering.
The duration is computed renderer-side from the per-session turnStartedAt
timestamp the app already tracks and stamped onto the ChatMessage at
completion (successful and failed turns alike). It is not persisted
backend-side, so messages hydrated from history have no badge — matching
how reasoning-block durations already behave.
Also adds the assistant.thread.turnDuration i18n key across all five
locale files.
The scoped find captures the foreground chat surface at bar-open time and
marks it `data-find-root`. A keep-alive tab flip — clicking another session
tab in the same stack — hides that surface with `data-pane-hidden` and
activates an unmarked one, without a pathname change, so the FindBar (which
only closes on a route change) stays open. Every subsequent query then
resolved no visible `data-find-root`, reporting 0/0 while the surface on
screen contained the text; Cmd+G / Cmd+Shift+G became no-ops.
currentFindScope now re-targets when every marked root is hidden: it
re-resolves to the foreground chat surface, clears the old surface's
highlights and marker (so they do not resurface when its tab is revisited),
stamps `data-find-root` on the new surface, and re-arms the re-render
watcher there. The normal case is unchanged — the scope stays on the
captured surface while it remains visible. Also restores the missing
trailing newline in store/find-in-page.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pressing Cmd/Ctrl+F in Hermes Desktop searched the entire webContents,
so every keep-alive chat surface matched — a user in chat A got hits from
chat B, every background tile, and every other pane rendering a transcript.
That's not what users expect from in-page find; it's a global search, and
it deserves its own shortcut.
Replace `webContents.findInPage` (whole-document, unscoped) with a
renderer-side DOM walker that captures the active chat surface at
bar-open time and only walks text nodes inside that subtree. The Electron
bridge is retained for secondary session windows (each window still
searches its own webContents); the primary window no longer drives the
bridge.
Scope decision. "Current view" is the foreground `[data-chat-surface]`
filtered through the existing pane-visibility helper, so an inactive
keep-alive tab can't accidentally answer the lookup. The scope is
captured once when the bar opens, not re-resolved on every keystroke,
so a mid-search route change can't silently re-home the highlights — the
FindBar's route-change cleanup closes the bar first.
Walker. Walks text nodes in document order, splits nodes that span a
match boundary, wraps each match in `<mark class="find-hit">`, and tracks
the active match by a single `data-find-active` attribute. Step uses the
existing marks when the query has not changed (no DOM churn) and re-
wraps when it has. Closing the bar unwraps every mark and normalizes the
parent text, restoring the original DOM byte-for-byte.
Tests. New `find-in-page-scope.test.ts` covers the walker in isolation:
multi-match wrapping, case-insensitivity, cross-node splits, script/style
filtering, no-self-match against the search overlay, scope retarget on
surface swap, and unwrap-on-release. Rewrote the FindBar store/component
tests to assert against the real DOM (marks + counts) instead of the
bridge mock — keeping the bridge mock only for the listener-refcount
cases that still cover the secondary-window path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The salvaged measurement effect only listened to window `resize`, so
opening, closing, or drag-resizing the files pane while the find bar was
up left the bar in a stale position (either still covering the pane's
header or floating mid-window after the pane closed).
Harden the measurement path:
- ResizeObserver on the aside tracks drag-resizes of the pane rail.
- A body-scoped childList MutationObserver catches the pane mounting or
unmounting mid-find-session and retargets the ResizeObserver when the
aside's identity changes (pane reopened, panes flipped).
- All triggers coalesce into a single rAF-batched measure; everything
(observers, listener, pending frame) tears down when the bar closes.
Also extends find-bar.test.tsx with three positioning tests: default
right-4 when the pane is closed, parked left of an open pane, and
re-measuring when the pane opens/closes while the bar is up.
The find bar is fixed to the top-right of the window, which overlays the
right sidebar's header and first file rows whenever the Files pane is
open. Measure the pane's live rect and park the bar just left of it;
fall back to the previous right-4 position when the pane is closed.
Closes#0
On Pop!_OS / GNOME-based Linux distros the GTK compositor grabs Ctrl+F at
the windowing layer (GNOME Files owns it) before the renderer's keydown
listener can fire, so the renderer's own `view.findInPage` keybind is
silently dead even though the binding is registered (#81727).
Claim the chord in the main process via `before-input-event` — that runs
strictly before the compositor shortcut can grab it. The renderer's
find-in-page pipeline still owns the FindBar UI and the actual search; we
just guarantee the press reaches it. The pre-existing `view.findInPage`
keybind stays as the renderer-side fallback for environments where the
compositor doesn't intercept.
Accept the platform's primary accelerator (Cmd on macOS, Ctrl elsewhere)
AND literal Ctrl on macOS so a non-macOS layout still works.
27/27 unit tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The desktop app carried its own hardcoded list of 17 vendor MCP endpoints
(apps/desktop/src/lib/mcp-directory.ts) powering the composer suggestion
pills — a second PR-reviewed vendor list, overlapping and drifting from the
Nous-approved MCP catalog (optional-mcps/).
This makes the catalog the single source of truth:
- manifest schema: optional `suggest:` block (keywords + hosts), parsed,
validated, and normalized in mcp_catalog.py
- 15 new URL-only hosted-remote catalog entries (atlassian, sentry, datadog,
notion, stripe, vercel, supabase, netlify, hugging_face, asana, intercom,
airtable, webflow, paypal, square); figma + linear manifests gain suggest
blocks
- GET /api/mcp/catalog now serves the suggest metadata
- desktop suggestion provider builds its match index from the catalog;
the static directory remains only as a compatibility rung for older
backends without suggest metadata
- setup card source line prefers the catalog entry's transport URL
GitHub stays out of the catalog on purpose: its hosted MCP rejects generic
DCR and the bundled github/* skills (gh CLI) are the stronger integration.
New desktop `github` suggestion provider offers the github-auth skill
instead — gated on a new cached GET /api/git/gh-auth probe so already-
authenticated users never see the pill.
- use-keybinds.ts: sort @/app/routes after @/app/chat/close-tab and the
right-sidebar imports (natural-asc)
- find-bar.test.tsx: sort @/app/hooks/use-keybinds before @/components and
@/i18n imports; KeybindRuntimeDeps before useKeybinds in named imports
Three fixes for the find-in-page bar (Ctrl+F):
1. Position: replace fixed right-4 with calc() that accounts for the
titlebar tool cluster width, preventing visual overlap with the
layout/haptics/keybinds/settings icons. Uses existing CSS vars from
wiring.tsx (--titlebar-tools-right, --titlebar-tools-width).
right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.5rem)]
2. Overlay guard: hide the find bar on full-screen overlay routes
(agents, command-center, cron, profiles, settings, starmap,
webhooks), matching the titlebar controls pattern. Prevents the
bar from rendering behind overlays at z-50 and avoids collision
with overlay-specific search surfaces (e.g. Settings search #69025).
3. Keybind gate: suppress the view.findInPage keybind on overlay
routes so Ctrl+F doesn't mutate store state with no visible effect.
Defense-in-depth alongside the component guard.
Adds regression test asserting the find bar does not render on /settings.
The ⌘F/Ctrl+F find bar positions itself at
top-[calc(var(--titlebar-height,0px)+0.5rem)], but it mounts at the
overlay root in ContribWiring, outside any subtree that defines
--titlebar-height. The 0px fallback parked the bar inside the 34px
titlebar strip, underneath the native min/max/close window-controls
overlay on Windows/Linux (two X buttons side by side, close button
half-covered).
Fix: use the real titlebar height (34px) as the fallback, matching the
established pattern in floating-hud.ts and notifications.tsx. The bar
now floats just below the titlebar band, clear of the window controls.
One config key everywhere (#41531): the same display.timestamps that stamps
[HH:MM] on classic-CLI labels now gates the desktop transcript's timeline
timestamps and renders dim [HH:MM] labels on TUI user/assistant rows.
- desktop: $displayTimestamps store fed from config.yaml via
use-hermes-config; TimelineTimestamp renders nothing while the key is off
(the default). Hover tooltips with the exact time stay ungated (#70450).
- TUI: tui_gateway forwards each persisted row's timestamp in the display
projection; toTranscriptMessages threads it as Msg.createdAt; live rows
are stamped at append (the #82840 rule); MessageLine shows a dim [HH:MM]
above user/assistant rows when display.timestamps is on.
- No new config keys, no HERMES_* env vars; display-only, prompt-cache safe.