Commit Graph

4143 Commits

Author SHA1 Message Date
Teknium f70277bc70 fix(desktop): arm the turn progress timer at submit instead of waiting for message.start
The progress box's timer (turnStartedAt) was only seeded by the backend's
message.start event, so the submit RPC -> gateway accept -> WS round trip
(seconds under load) showed no timer at all. Seed the per-session clock in
seedOptimistic at Enter-time; message.start now keeps an existing seed
(?? Date.now()) so backend-originated turns still arm there, the active-
session mirror reuses the seeded value instead of snapping to accept-time,
and the abort/failure paths retire the seed with the turn. Adds a
console.debug submit->accept latency probe at message.start.
2026-08-15 04:22:54 -07:00
hermes-seaeye[bot] 3d5f450781 fmt(js): npm run fix on merge (#86915)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-15 11:02:12 +00:00
Teknium 3671529e9d feat(sdk): export route-decoupled McpTab + ToolsetConfigPanel for plugins (#86896)
* feat(sdk): export route-decoupled McpTab + ToolsetConfigPanel for plugins

Runtime plugins can only import from @hermes/plugin-sdk, but the real
Capabilities components (the full per-toolset config panel and the full MCP
tab with OAuth/API-key setup) were never exported there — so a plugin could
only reimplement bare checkbox lists. Export both, route-decoupled so they
render safely outside the Settings react-router context:

- toolset-config-panel.tsx: useOptionalNavigate() wraps useNavigate in try/catch
  (returns null with no router); the 'manage keys' deep link becomes a no-op
  when embedded outside Settings. In-Settings behavior unchanged.
- use-deep-link-highlight.ts: useOptionalSearchParams() degrades to inert params
  with no router (shared by 4 in-Settings callers incl. McpTab; identical there).
- sdk/index.ts: export { McpTab }, export { ToolsetConfigPanel }, export type
  HermesGateway, and host.getGateway() returning the live $gateway instance
  (McpTab takes a HermesGateway prop; plugins had no way to get the instance).

Both components are already profile-aware (profile?: null|string, #86548), so a
plugin can scope them to a specific bot profile. tsc: 0 errors (unchanged from
baseline). Enables Hermes-Bot-Mode to show the real Tools+MCP config in the bot
editor instead of checkbox stand-ins.

* fix(lint): sort the new SDK exports into perfectionist/sort-exports order

CI check:lint failed — the capabilities exports were grouped by comment instead
of interleaved into the file's path-sorted export list. Place them at their
natural-ascending positions: ToolsetConfigPanel (@/app/settings) after @/app/routes,
McpTab (@/app/skills) after @/app/shell/*, HermesGateway (@/hermes) after
@/contrib/types. Verified 0 adjacent-unsorted export pairs.

---------

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
2026-08-15 03:55:51 -07:00
Teknium aff19d0251 feat(desktop): multi-source agents end-to-end — sockets, roster, SDK, fan-out updates
Phases 3-5 of the multi-connection campaign in one PR (per Teknium), on top
of the registry (#86679) and composite-key backend routing (#86839). Agents
from every registered connection are now usable side by side.

Renderer socket registry (phase 3):
- backendScopeKey moves to apps/shared (@hermes/shared) so main-process pool
  keys and renderer socket keys derive from ONE rule; the electron module
  keeps a byte-identical twin (tsconfig project boundaries) pinned by a
  cross-copy contract test.
- store/gateway secondaries are scope-keyed: entries carry (connectionId,
  profile); registry-scoped entries dial through getConnectionFor +
  getGatewayWsUrlFor (fresh per-connect OAuth tickets against the right
  host); events keep the bare profile plus a connectionId tag; touch/idle
  keepalive uses the scope key; pruning keeps entries whose PROFILE has live
  work. New ensureGatewayForAgent/openGatewayForAgent fall through to the
  profile path for local/null sources — single-source behavior byte-identical.

Union roster + plugin SDK (phases 3+4, the Bot Mode door):
- hermes:agents:roster enumerates every connection's /api/profiles
  concurrently (eager REST, lazy sockets; unreachable sources report per-row;
  undialed ssh boxes stay connect-on-demand) and flattens through
  buildAgentRoster — the @name-device duplicate-handle rule applied once
  across all sources, pure + tested.
- SDK: host.connections(), host.agents(), host.warmAgent(),
  host.ensureAgent() — feature-detected so plugins degrade cleanly on older
  Desktop builds.

Fan-out updates (phase 5):
- hermes:connections:update-all dispatches hermes update to every eligible
  source in parallel: local via the app's own applyUpdates pipeline,
  remote/ssh via the backend's own POST /api/hermes/update; cloud skipped as
  platform-managed (updateEligibility, pure + tested); per-connection result
  rows so one dead box can't wedge the batch. Settings → Connections gains
  the "Update all instances" button (shown with 2+ connections).

Also: getJsonForBackend/postJsonForBackend helpers with the token/OAuth-cookie
auth split; docs section updated from "staged rollout" to live behavior.

Tests: +4 pure cases (cross-copy contract, roster handles, unreachable
sources, update eligibility); FULL desktop suite 5115 passed; tsc renderer +
electron + shared clean; eslint clean.
2026-08-15 03:11:32 -07:00
hermes-seaeye[bot] 5b05e51f2e fmt(js): npm run fix on merge (#86876)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-15 09:33:47 +00:00
Chen Jin 0d1828294a fix(desktop): re-target find scope on a keep-alive surface flip (#81726)
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>
2026-08-15 02:28:07 -07:00
Chen Jin 041cbb5e48 style(desktop): add missing trailing newlines to find-in-page files (#81726)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin c856c5f6db fix(desktop): keep find-in-page highlights consistent across React re-renders (#81726)
The scoped find walker wraps transcript text nodes in <mark> elements that
React does not own. Assistant responses stream through markdown-text.tsx,
which rebuilds the markdown DOM on every delta, and a new message is
appended whenever the assistant answers — so a re-render of a changed
region detaches the marks we inserted, dropping the user's highlights while
the bar stays open.

Watch the captured scope with a MutationObserver and re-wrap only when an
unmarked occurrence of the active query actually reappears. The observer is
gated behind a re-entrancy flag while the walker is mutating, coalesced to
one re-apply per microtask, torn down when the bar closes or the query
clears, and restores the active ordinal so a mid-stream re-render doesn't
reset the user's place to match #1. An append that adds no matching text is
a no-op; re-wrapping only fires when highlights genuinely went stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin 4e86caa1e2 fix(desktop): re-wrap on findNext when stale marks no longer cover all matches (#81778)
The fast path trusted that marks matching the query case-insensitively
implied the set still covered every match. A stale mark from an earlier
query (raced re-wrap, external DOM writes) can pass that per-mark
comparison while live occurrences stay unwrapped — stepping then walks
old highlights and the new matches never light up. Gate the fast path on
a read-only coverage check (same skip rules as the walker) so any
unmarked occurrence forces a re-wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin 0ea7349336 fix(desktop): case-insensitive fast-path in find-in-page stepping (#81778)
Triage found the findNext fast path compared each existing `<mark>`'s
textContent byte-for-byte against the typed query. Since highlightMatches
stores the ORIGINAL-CASE source slice, the first differently-cased match
('Hermes' for 'hermes', sentence-initial capitals, ALL-CAPS) failed the
check, forcing a full re-wrap on every Enter/⌘G: fresh `<mark>` elements
lose `data-find-active`, `previousActive` resolves null, and the active
ordinal stays 1 forever (backward steps land on the last match every
time).

Compare case-insensitively. Regression test pins the differently-cased
stepping behavior: same marks survive, count stays 2, ordinal advances
to 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin 7ebef79c65 fix(desktop): resume sibling traversal after a fully-consumed text node match
Team review (#81778) found the walker terminated sibling traversal when a
text node was entirely consumed by a match: `current = textNode ? ... :
null` — after replaceChild detaches the original node, its `.nextSibling`
reads null, so `<div>needle<span>needle</span></div>` searching "needle"
matched only the first occurrence. Any JSX bare-text + element sibling
pattern (tool rows, attachment rows) could silently drop later matches.

Capture the text node's own `nextSibling` BEFORE the first replaceChild
and resume the outer walker from it on full consumption. The `after`
split path is unaffected (the trailing text node is re-scanned).

Regression test pins `<div>needle<span>needle</span></div><p>needle</p>`
→ 3 matches. 17/17 scope tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:28:07 -07:00
Chen Jin ab3b7394b1 fix(desktop): scope Ctrl+F find to the current chat view (#81726)
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>
2026-08-15 02:28:07 -07:00
Jony ca8a47e276 fix(desktop): keep find bar out of its results 2026-08-15 02:20:23 -07:00
Teknium e0c73bfece Merge pull request #86847 from NousResearch/salvage/72959-titlebar-tool-count
fix(desktop): count all five static titlebar buttons; find-bar overlay guard (salvage #72959)
2026-08-15 02:14:49 -07:00
Teknium d8536709b7 fix(desktop): re-measure find-bar offset when the files pane toggles or resizes
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.
2026-08-15 02:06:37 -07:00
songoten eaef39df18 fix(desktop): position find bar clear of the files pane when open
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
2026-08-15 02:06:37 -07:00
Teknium 279ec2f75b style(desktop): blank line before statement in open-find-bar release (lint) 2026-08-15 02:05:42 -07:00
Chen Jin f5e531ded0 fix(desktop): gate the Ctrl/Cmd+F main-process hook to Linux only (#81727)
The before-input-event handler was registered on every platform, so on macOS
and Windows — where the renderer's rebindable view.findInPage keybind already
owns Ctrl/Cmd+F — the chord became un-rebindable and would double-open when a
user remapped or cleared it. Restrict the install to process.platform ===
'linux' (the only platform #81727 affects); mac/Windows keep the renderer's
own keybind registry path.

Also corrects the docstring: the prior claim that 'GNOME Files owns Ctrl+F at
the windowing layer' is not accurate (Nautilus does not install global grabs).
The interception layer varies by distro/desktop; the fix sidesteps it by
acting at before-input-event regardless of cause, which is what actually
matters. The handler function itself stays platform-agnostic and injectable
for tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Chen Jin 75f0602301 test(desktop): cover the real macOS Cmd+F chord in the main-process handler (#81727)
IS_MAC was baked from process.platform at import time, so the unit test
titled "Cmd+F (macOS primary accelerator)" could never hit the meta (Cmd)
branch — it actually sent Ctrl and only exercised the literal-Ctrl-on-macOS
fallback. Make the platform detector an injectable default parameter and:
  - assert meta+Cmd on macOS opens the FindBar (the real Cmd path),
  - pin the dual-channel design (literal Ctrl also accepted on macOS),
  - cross-check that meta alone does NOT open on Linux/Windows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Chen Jin 2d51f6d022 style(desktop): use top-level BrowserWindow type import in find-in-page tests
`as unknown as import('electron').BrowserWindow` trips
@typescript-eslint/consistent-type-imports ("import() type annotations are
forbidden"). Use the top-level `import type { BrowserWindow }` instead,
which the lint gate accepts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 02:05:42 -07:00
Chen Jin d3193da3b1 fix(desktop): claim Ctrl/Cmd+F in main process so Pop!_OS / GNOME doesn't eat it
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>
2026-08-15 02:05:42 -07:00
Teknium 883b57cbd8 feat(desktop): route backends by (connection, profile) — registry-scoped pool keys
Phase 2 of the multi-connection campaign (#86679 shipped the registry).
The Electron backend pool can now serve agents from ANY registered
connection concurrently, keyed by composite (connection, profile) scopes.

- connection-registry.ts: backendScopeKey(connectionId, profile) — the
  single home of the composite-key rule. Local/empty connection ids keep
  the BARE profile key, so every legacy pool entry, reaper log line, and
  touch call is byte-identical for single-source users; non-local
  connections get `conn:<id>::<profile>`, which cannot collide with a
  plain profile name. backendScopePrefix() matches the keys a connection
  owns (teardown on remove).
- main.ts ensureRegistryBackend(connectionId, profile): resolves a backend
  against the v2 registry. local kind delegates to ensureBackend()
  untouched; remote/cloud dial the entry's own URL/auth (descriptor carries
  profile + connectionId + sharedRemote for per-request ?profile= scoping);
  ssh bootstraps a tunnel scoped to the composite key, with the served
  dashboard token persisted back onto the REGISTRY entry (not v1
  connection.json). Pool entries reuse the existing LRU/idle-reaper/touch
  lifecycle.
- hermes:connections:remove now stops every pooled backend + ssh scope the
  removed connection owns.
- New IPC hermes:connection:for + preload getConnectionFor + renderer
  types (connectionId/sharedRemote on HermesConnection). No renderer
  behavior change yet — the multi-source roster/socket switchover is PR 3.

Tests: +2 backendScopeKey contract cases (28 total in the registry suite);
electron + settings projects 1357 passed; both tsc configs and eslint clean.
2026-08-15 02:04:30 -07:00
tyfpro d059239aa4 fix(desktop): space out find-bar buttons and anchor below titlebar 2026-08-15 02:04:10 -07:00
Teknium fc8ebff6d8 feat(mcp): unify the desktop MCP suggestion directory into the catalog
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.
2026-08-15 02:03:24 -07:00
Teknium 001e2b0b85 test(desktop): add archiveSelectedSession to keybind harness deps
KeybindRuntimeDeps grew archiveSelectedSession on main after #72959 was
opened; the salvaged keybind-gate harness needs the new field to typecheck.
2026-08-15 01:56:31 -07:00
David Metcalfe 903250772f style(desktop): satisfy perfectionist import ordering in find-bar PR files
- 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
2026-08-15 01:56:31 -07:00
David Metcalfe 16a1ad6ef4 fix(desktop): count the right-sidebar toggle in the controller's titlebar slot too 2026-08-15 01:56:31 -07:00
David Metcalfe d0ba405ab7 test(desktop): cover the view.findInPage overlay keybind gate 2026-08-15 01:56:31 -07:00
David Metcalfe a1b24c52f7 fix(desktop): count the right-sidebar toggle in the titlebar tools width 2026-08-15 01:56:31 -07:00
David Metcalfe 4e8e991d61 fix(desktop): find bar positioning, overlay guard, and keybind gate
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.
2026-08-15 01:56:31 -07:00
Benjamin Ang ad42ecfc06 fix(desktop): stream remote media without renderer credentials 2026-08-15 01:53:34 -07:00
Teknium 5ed4506f42 fix(desktop): FindBar no longer overlaps native window controls
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.
2026-08-15 01:46:51 -07:00
hermes-seaeye[bot] f0c222c73d fmt(js): npm run fix on merge (#86820)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-15 08:27:29 +00:00
Teknium eb4f72f914 test: satisfy new unread props at remaining sibling fixture (session-row Tip test) 2026-08-15 01:21:40 -07:00
Teknium 7f921871e7 test: pass onToggleUnread in wheel-overscroll VirtualSessionList fixture (cross-PR prop addition) 2026-08-15 01:21:40 -07:00
Teknium 0ceb739abc style: eslint --fix on salvaged unread-dot files 2026-08-15 01:21:40 -07:00
Teknium b44b99e62a fix(desktop): integrate the unread read-state layers — focused-session clear acks persistence, mark-all/mark-read ack watermarks, new-turn baseline reset 2026-08-15 01:21:40 -07:00
Nicolas Formenton c75d835555 feat(desktop): mark a session as unread/read with a persisted watermark 2026-08-15 01:21:40 -07:00
OpenClaw Agent 71e8c35285 fix(desktop): clear unread for the whole conversation family, don't re-light read sessions
The sidebar lights the finished-unread dot for every alias of a
conversation lineage (branch children + compression root), but reading
a session cleared only the exact row id — a branched/compressed
conversation kept dots lit on sibling rows no matter how often they
were opened. And any settled completion re-lit the dot even when the
user had already viewed the session since it finished.

- setSelectedStoredSessionId now clears unread for the whole family via
  lineageAliases, not just the selected id
- handleTransition only re-arms unread when the completion settles
  strictly after the user's last read of that session (new last-read
  baseline), so an already-viewed completion never re-lights
- openSession marks read at the very top, before any focus
  short-circuit, so re-clicking an already-visible session clears its
  dot (the original gap the sidebar click could not reach)

4635 desktop tests pass (incl. new family-clear, read-baseline, and
openSession-short-circuit cases); tsc typecheck clean.
2026-08-15 01:21:40 -07:00
gitong 1abeddacc1 fix(desktop): clear unread dot on tile open + add mark-as-read actions
The green 'finished-unread' dot only cleared when a session was opened via
main-thread resume (setSelectedStoredSessionId). Opening a session in a
tab/tile (middle-click / Cmd-click / tile strip) never cleared it, so the
dot stayed while the user was actively reading the session in a tile.

With a remote hermes serve backend the effect is amplified: session.info
transitions for every backend session (CLI/cron/kanban) mark unread in the
desktop client, so unread dots accumulate from sessions the user never
opened.

Changes:
- openSessionTile now calls markSessionRead(), so tile/tab open marks the
  session read (same as main-thread resume)
- new markSessionRead / markAllSessionsRead helpers in store/session,
  reused by setSelectedStoredSessionId
- 'Mark as read' per-row action in the session context menu (shown only
  while the row is unread)
- 'Mark all as read' header action in the recents sidebar (shown only
  when unread sessions exist)
- i18n: markRead (row scope), markAllRead (sidebar scope) in en/zh + types
2026-08-15 01:21:40 -07:00
Zeus-Deus 26b4315e09 fix(desktop): profile-scope persisted unread and stop cold-boot storage clobber
Address review on the persisted unread dots, plus a latent data-loss bug
in the shared persistence helper that the restart e2e exposed.

Review findings:

- Session ids are caller-supplied and each profile backend is its own
  namespace, while the desktop's lists routinely mix profiles (cron and
  messaging slices are always cross-profile; recents are too in
  all-profiles mode). Both persisted records are now bucketed per
  profile - nested records keyed by the ROW's own profile
  (normalizeProfileKey, absent -> default), never the live gateway's,
  except the live busy->idle edge with no loaded row, which can only
  come from the active gateway. Same-id sessions in different profiles
  no longer share watermarks or markers.
- Markers are now bounded (200 per profile, oldest evicted) and cleaned
  up when a session leaves the user's world: forgetSessionUnread() is
  wired into removeSession, archiveSession, and the settings
  permanent-delete path (which bypasses the other two).

Cold-boot clobber (found by the restart e2e after the refactor):

- persistentAtom wrote its value back to storage immediately at
  creation. On a cold boot the bundle can evaluate against a storage
  snapshot that has not caught up yet, so that echo overwrote real
  records with the fallback. Creation is now read-only; only actual
  changes persist. Regression-tested in persisted.test.ts.
- The read side of the same race is handled in session-unread.ts: the
  first list arrival re-reads both records from storage (readable by
  then) and merges them under the in-memory state, so a boot that
  seeded empty atoms adopts the disk state instead of re-seeding every
  row and burying the unread gap. Unread listeners are also disabled in
  secondary windows - their partial list view must not write the
  primary's whole-record state (same isolation rule as session tiles).

Tests: cross-profile same-id regression, live-edge profile fallback,
forgetSessionUnread cleanup, marker cap, persistentAtom creation
read-only; the restart e2e passes again end to end.
2026-08-15 01:21:40 -07:00
Zeus-Deus d2c2b0b6d4 fix(desktop): persist sidebar unread dots across app restarts
The green "finished — unread" session dot lived only in the transient
$unreadFinishedSessionIds atom, written by a live busy->idle edge the
renderer had to witness. Closing and reopening the app grayed out every
dot, and a session that finished while the app was closed could never
be flagged at all.

Add a persisted layer (session-unread.ts), ported from the webui's
proven design:

- Seen watermarks (hermes.desktop.sessionSeenCounts): the message_count
  last acknowledged per session, keyed by the durable lineage id (same
  rule as session colors). A row whose live count exceeds its watermark
  paints unread on every list refresh - this reconstructs dots after a
  restart AND surfaces sessions that finished while the app was closed.
  First sight of an unknown session seeds the watermark so a fresh
  install doesn't light up every row.
- Explicit finish markers (hermes.desktop.unreadFinishedSessions): the
  live edge, persisted, covering the gap before the sidebar list
  refreshes its counts.

Opening a session acks both; the selected session's watermark tracks
its live count so on-screen activity never reads as unread. Chat and
cron rows get full watermark treatment; messaging rows keep explicit
markers only, so inbound messages don't paint false completion dots.
Profile switches keep persisted markers (keyed by durable id) and only
wipe the transient paint layer, so a round-trip repaints them.

Covered by store unit tests and an e2e spec that boots the app three
times: dot appears on a background finish, survives a restart, clears
on open, and stays cleared after another restart.
2026-08-15 01:21:40 -07:00
Jefftree a5b50437e4 fix(desktop): key the completed-unread dot on the focused session, not the selected one 2026-08-15 01:21:40 -07:00
hermes-seaeye[bot] 77be513de1 fmt(js): npm run fix on merge (#86813)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-15 08:13:01 +00:00
Teknium 77fcc2ea31 feat(display): honor display.timestamps across desktop transcript and TUI
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.
2026-08-15 01:04:19 -07:00
Chen Jin 4a663dd693 fix(desktop): sort imports to satisfy perfectionist lint (#84508) 2026-08-15 01:04:19 -07:00
Chen Jin 1dbeb47497 fix(desktop): timestamp every desktop.log / RECENT LOGS line
rememberLog prepended only '[hermes] ' to each line, so desktop.log and
the in-app RECENT LOGS view carried no timestamps while agent.log and
gateway.log (Python logging) did.

Extract the line format into a small pure helper (desktop-log-line.ts)
and prefix each line with an ISO-8601 UTC timestamp shared per chunk,
matching the Python-side convention. Regression tests assert the shape
contract: timestamp + [hermes] tag + verbatim message. Fixes #84405.
2026-08-15 01:04:19 -07:00
liguoyu c9d0cbaba3 fix(desktop): show exact timestamps in tooltips (#70450) 2026-08-15 01:04:19 -07:00
Nick Morales 5f2a15a669 fix(desktop): separate system timestamp text 2026-08-15 01:04:19 -07:00
Nick Morales a896322b46 feat(desktop): show detailed transcript timestamps 2026-08-15 01:04:19 -07:00