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.
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
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.
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.
The dashboard maps Ctrl+Delete to ESC d for delete-word-forward, but the
composer had no binding for it: hermes-ink decodes ESC d as meta+'d', which
fell through to the printable path and typed a literal "d" instead of
deleting the next word. Add a meta+d branch that mirrors the existing
Ctrl+W delete-word-backward, sharing a deleteWordForward helper with the
Delete+word path.
The new cut() wrote the clipboard fire-and-forget and removed the selected
text immediately, so on a headless/SSH box with no clipboard backend the
write fails and the text is lost with no copy to paste back. Make cut
transactional via cutSelection(): await the write and only remove the
selection when it succeeds; on failure the selection stays intact. The
removal also re-checks the selection to avoid slicing with stale offsets
after the awaited write.
The word-delete branches sent ^W / ESC d whenever the socket was OPEN,
bypassing the shouldBlockPtyInput gate that term.onData applies to
every other keystroke, so a reconnecting/closed session could still
receive shortcut bytes. Route both branches through a shared guarded
sender that applies the identical socket + PTY state check, and cover
the non-open PTY states in the lib tests.
The dashboard chat is an xterm.js terminal in a browser tab, so several
editing keys never reached the input:
- Bare Ctrl+V fell through to the TUI, whose server-side clipboard read
can't see the browser/OS clipboard, so it reported "No image found in
clipboard". Route Ctrl+V through the same navigator.clipboard path as
Ctrl+Shift+V (image-or-text). Fixes#24860.
- Ctrl+Backspace / Ctrl+Delete never sent a word-delete: xterm.js emits
bare DEL regardless of modifier. Send ^W (0x17) and Alt+d (ESC d) to the
PTY so readline / prompt_toolkit delete the previous / next word.
Ctrl+W itself stays unavailable in a browser tab: it is a reserved
shortcut (close tab) that preventDefault cannot suppress. Ctrl+Backspace
covers word-delete there; the Electron desktop app can bind Ctrl+W.
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.
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.
#76185 removed both defaults; only the bare shift+n chord hijacks normal
typing (uppercase N / IME input outside an input field). Cmd/Ctrl+N is a
deliberate two-key chord that matches every browser and chat app, so it
stays. Follow-up narrowing of the salvaged fix.
- Add disableF12Title/disableF12Desc to i18n/types.ts contract
- Use focused BrowserWindow from menu click callback
- Persist and restore disable-f12 from main process (cold-launch)
- Replace built-in menu role 'toggleDevTools' (which had F12 accelerator)
with explicit menu item using Ctrl+Shift+I / Cmd+Opt+I only
- Add f12Blocked flag in main process, controllable via IPC
- Add 'Disable F12 DevTools' toggle in Settings → Advanced
- F12 still opens DevTools by default; toggle blocks it
- Ctrl+Shift+I (or Cmd+Opt+I on Mac) always works regardless
Adds a rebindable 'session.archive' keybind action (shipped unbound, like
session.togglePin) plus an ⌥+⇧-click gesture on sidebar session rows,
extracted into a pure, unit-tested click resolver so modifier precedence
(⌥⇧ archive vs ⇧ pin vs ⌘/⌃⇧ new window) stays correct.
Salvaged from #59759. Closes#59308.
9c75e4863f added the global ⌘⇧G snap-to-cursor shortcut and wired its
dispose() into closeHudWindow() and before-quit. It missed the HUD's
own 'closed' listener (spawnHudWindow's win.on('closed', ...)), which
fires when the window is closed from its own side — e.g. ⌘W — without
going through closeHudWindow() first.
After a ⌘W close, the shortcut stays registered with no HUD left to
apply it to: harmless (applyHudSnapToPointer guards on a destroyed/null
hudWindow) but it keeps CommandOrControl+Shift+G claimed until the HUD
is reopened (register() releases first) or the app quits.
dispose() is idempotent (guards on its own `active` chord), so calling
it unconditionally in the 'closed' handler is safe even on paths where
closeHudWindow() already released it.
The session.new default keybindings included 'mod+n' and 'shift+n',
which fired when users typed uppercase N (Shift+N) or accidentally
pressed Ctrl+N while using an IME to type Chinese — silently creating
new sessions mid-conversation.
Remove both combos so New Session is only reachable via the sidebar
button. Ctrl+Shift+N (session.newWindow) is unaffected.
- use-keybinds: bail out of the global keydown dispatcher while an IME
composition is active. Windows Chinese IMEs use Ctrl+, as their
punctuation toggle, which also matched nav.settings and navigated away
mid-word — destroying the unsent composer draft (#41079).
- use-composer-draft.test: regression-pin the unmount-stash/remount-restore
contract so route navigation (Settings) can never again drop an unsent
draft with the React tree.
- use-composer-actions.test: the bounded-preview pipeline keys attachments
to the durable path and resolves thumbnails asynchronously; the dropped-
screenshot test now asserts the durable-path contract instead of the
retired full-res previewUrl field.