Round out the credit-notice handling from the previous commit with the two
optional pieces from the issue:
- Native OS notification for the urgent pair. `credits.depleted` /
`credits.restored` also fire an Electron notification when Hermes is
backgrounded, via a new `credits` NativeNotificationKind (the existing
five didn't fit) with its own toggle in Settings → Notifications (the
panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and
i18n are the only additions). The escalating usage line and grant-spent
notice stay in-app toasts only. Dispatch is `global` (account-wide, not
session-bound) and gated by the user's prefs + backgrounded check.
- Billing-page nudge. A `credits.*` crossing invalidates the
`['billing','state']` query so Settings → Billing reflects the change
immediately instead of waiting up to 30s for its poll.
`nativeNoticeInput()` is a pure mapping (urgent-key gate → native input),
unit-tested directly; the gateway-event branch does the localized-title
lookup and gated dispatch. i18n added for all four locales.
The desktop renderer had no handler for the `notification.show` /
`notification.clear` WS events, so every credit-usage notice the backend
sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently
dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never
appeared, even though the Ink TUI renders them in its status bar.
Add the two missing branches to the gateway-event dispatcher, delegating
to a small, pure-testable module:
- `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast
(level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the
notice `key` as the toast id. Re-emitting the same key REPLACES the
toast, so the credits 50→75→90 line escalates in place instead of
stacking, and a key-matched `notification.clear` maps straight to
`dismissNotification(key)`.
- The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast
icon is added.
- Notices are account-wide, so the toast shows regardless of which
session is focused.
The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the
latest-wins / sticky-vs-ttl / key-matched-clear behavior.
Export `NotificationInput` so the mapping's return type can be named.
* fix(desktop): improve fallback tool-call details
Show failed image-generation calls through the normal fallback row, remove duplicate normal-mode web-search JSON, and format Technical Mode payloads as readable JSON.
* feat(desktop): render terminal tool calls as transcripts
Show terminal commands with a prompt and exit status, then reveal ANSI-safe stdout and stderr in the expanded tool row.
* fix(desktop): reconcile tool calls by command
Match context-only tool starts with command-bearing completions when their IDs differ, preventing stale duplicate terminal rows. Show the web-search query above its result cards.
`resumeTile` — the cold-resume path for a session opened in a tile / split
pane — resumed with `{ session_id, cols }` and read messages with no profile,
so a tile opening a session from another profile let the gateway fall back to
the launch-profile DB and fork the conversation into the wrong profile: the
same cross-profile bleed the recovery resumes had (#67603), just a sibling
call path. Resolve the owning profile via the shared `resolveSessionProfile`
and carry it on both the transcript prefetch and the resume RPC.
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Narrow the setup-error matcher, match the server's empty-key contract,
and route gateway-event plus create/resume/branch through one
credential-warning policy. Preserve configured=true only for
non-authoritative transport fallback.
Co-authored-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Brandon R <kingdomwarrior23@gmail.com>
* fix(desktop): stop model-switch dup + route recovery resumes to the owning profile
Fixes two Desktop session-reconciliation symptoms from #67603.
Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.
Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).
Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.
Supersedes #68665 and #63590.
Closes#67603.
Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
* fix(desktop): scope the remembered session id per profile
A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).
Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
* fix(gateway): hide [System:] bookkeeping markers from every transcript
Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.
Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
---------
Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).
Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Fixes two Desktop session-reconciliation symptoms from #67603.
Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.
Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).
Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.
Supersedes #68665 and #63590.
Closes#67603.
Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
- Replace undefined CSS class with Tailwind v4's
so long choice text wraps properly
instead of overflowing the button container.
- Add validation that strips non-string items,
blanks, newlines, and text >200 chars — preventing garbage/JSON
arrays from rendering as raw values.
- Add diagnostic logging at both the gateway event
handler and the tool-args parser when choices are dropped, so
malformed payloads are no longer silent.
- Apply in both the gateway event handler
() and the inline tool-args parser
() for consistent defense in depth.
- Add unit tests for covering null, non-array,
mixed-type, blank, multiline, and overlong inputs.
Closes#69122
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
* fix(desktop): keep clarify lifecycle when tool progress is off
* fix(desktop): render clarify prompt from the request event
Re-authored onto the current use-message-stream/gateway-event.ts (the
original patched the pre-split use-message-stream.ts). When the tool.start
row that normally mounts the inline clarify UI is missed (stream reconnect
/ hydration race), upsert a stable pending clarify tool row from
clarify.request itself so the prompt stays answerable; a real
tool.start/complete with the same request id merges rather than duplicates.
Co-authored-by: 정수환 <centerid@naver.com>
* chore(contributors): map centerid@naver.com -> lidises
Attribution mapping for the salvaged #47544 commit.
* fix(desktop): correlate clarify rows by question so hydration can't duplicate
The hydrated row (from clarify.request's request_id) and the real tool.start
row (the model's tool_call_id) have different ids, so id-only matching appended
a second clarify card in the normal path (caught by the BLOCKING_CLARIFY e2e:
'question' resolved to 2 elements). Add 'question' to the tool match-value keys
so a clarify upsert merges into the existing pending clarify row regardless of
id (same request<->args correlation ClarifyToolPending already uses); when no
row exists yet (reconnect/hydration) it still creates one.
---------
Co-authored-by: 정수환 <centerid@naver.com>
`ExpandableBlock` used a single full-width `absolute inset-x-0 bottom-0`
button that was BOTH the fade overflow cue and the expand/collapse control.
It sat on top of the whole 28px bottom strip, so it swallowed:
- the horizontal scrollbar of a wide code block (couldn't scroll sideways), and
- pointer/drag events on the block's last line (couldn't select/copy the tail).
Split the two responsibilities: the full-width fade stays as a pure
`pointer-events-none` cue, and the only clickable target is a compact toggle
pinned to the right edge (`pointer-events-auto`), clear of the draggable
scrollbar track. The inner container gains `overflow-x-auto` so wide code
gets a working scrollbar. Applies uniformly to code cards and the plain-text
fallback — no per-call prop needed.
Regression test asserts the pointer-events contract (fade `pointer-events-none`
+ full-width, toggle `pointer-events-auto` + right-pinned, not `inset-x-0`) and
that the toggle still flips `aria-expanded`.
Supersedes #69558, #69428Fixes#69168
Co-authored-by: alelpoan <alelpoan@proton.me>
Co-authored-by: BerneYue <xiongyue_hnu@163.com>
Keep Enter as the Cursor-style live-turn steering gesture and assign
Ctrl/Cmd+Enter to queue the current draft. Align keybind settings,
tooltips, translations, and the Electron queue-boundary coverage.
removeSession/archiveSession now pin their tombstone via beginSessionMutation
until the RPC settles (finally → endSessionMutation), keyed per id so
concurrent deletes across worktrees stay independent. This lets the
projects.tree prune hold the optimistic removal through the whole in-flight
window — no shared lock, no serialization.
Drops the now-redundant post-success re-filter band-aid in archiveSession:
the refresh honors tombstones generically, so there's nothing left to win.
A refresh racing an in-flight delete/archive resurrected the just-removed
row: refreshSessions repopulated $sessions straight from the backend page
ignoring tombstones, and the projects.tree prune dropped a tombstone the
moment its id left scoped_session_ids — so the grouped lane un-filtered it
too. The row only vanished on a later refresh once the RPC finally landed.
refreshSessions now filters tombstoned rows (and their lineage tip) before
merging. The prune keeps a tombstone while its mutation is still in flight
locally via $sessionMutationsInFlight, then hands it back to the normal
scoped-based prune once settled.
Clicking '+' on a collapsed worktree/branch lane (or repo) created a session in a folder the user couldn't see. It now force-expands the target node.
The root cause was that collapse state was stored as an XOR override of defaultOpen. defaultOpen flips for a worktree lane (collapsed while empty, open once it holds a session), so an explicit expand of an empty lane silently re-read as a 'collapse' the moment the lane gained its first row - collapsing the very lane you'd just opened to work in. Store the resolved open/collapsed boolean per node instead ($sidebarWorkspaceNodeOpen), which survives the default flip; one-time migration off the old set. The review file-tree shares this store and is updated to match.
The sidebar row and the pane tabs each painted their own dot from different data: the sidebar read color from $sessionColorById[id] (map-only, no resolver fallback) layered with live state, while a tab painted a flat 'accent' color via sessionColorFor (map + fallback). A session older than the recents page missed the map, so the same session showed grey in the sidebar and its project color in the tab.
Add a single SessionStatusDot primitive keyed by the stored session id (the key every live-state atom already uses) that resolves color (override -> project, with fallback) and live state (working/needs-input/stalled/unread/background) itself. The sidebar row, session tiles, and the main workspace tab all render it, so a session's status/color can never disagree across surfaces. Tabs gain the full live status (pulse etc.), not just a static color. Collapses the now-orphaned generic 'accent' tab-dot path into the one primitive.
Clear the coding rail while a session's workspace changes and discard late
Git status results for the previous cwd. This prevents Ctrl+Shift+B from
opening a worktree dialog against an old branch.
- ⌘W on the main tab promotes the next stacked session tab into main
- '+' / ⌘T open a new 'New session' tab, unlisted until first message
(no sidebar pollution); multiple new-session tabs allowed
- tile resolves its own row via by-id lookup once it has a message
- Extract resolveSessionColor(): the sessionColorFor fallback no longer
re-implements the override -> project-color precedence that the
$sessionColorById computed already owns.
- tileStoredRow: collapse the nested project-tree walk into a flatMap +
find, matching the local style.
The no-native-title guard (added on main after this PR's first CI run)
bans native title= on <button>. Wrap the shared ChoiceButton in the
themed <Tip>, which renders the child untouched when the label is falsy
so the live card is unaffected.
A tab group rendered only the active pane's content, so every tab switch
unmounted and remounted the whole surface — revisiting a session tab
re-measured and re-scrolled the thread from scratch, visibly shifting
layout each time.
Panes that have been active in a zone now stay mounted in absolutely
positioned layers; the inactive ones hide via visibility (keeping their
layout box, so scroll positions and measurements survive) with
pointer-events disabled and aria-hidden. Mounting stays lazy — a pane
first mounts when first activated — so a boot-restored tab stack still
doesn't resume every session up front, and panes that leave the zone
(closed / moved) unmount as before.
Opening a session in a new tab left the tab titled 'Session' with no
project-color dot until new activity landed the row in the paginated
recents list. Two gaps:
1. tileTitle/tileAccent/tileDragPayload only looked the stored row up in
$sessions (the recents page). Sessions opened from a project group are
often older than that page, so the lookup missed entirely. Resolve
through the project tree as a fallback (tileStoredRow) and re-sync pane
titles/accents when $projectTree loads.
2. Even with the row present, liveSessionProjectId returned null for a
session whose cwd sits outside its recorded git_repo_root (mid-session
relocation / sibling worktree), because the cwd-under-root guard ran
before the explicit-project folder match. The backend tree groups such
rows under the project; the client now agrees — an explicit folder
match is authoritative, only the auto-project (repo root) fallback
still needs cwd-under-root confidence.
sessionColorFor also computes directly (overrides -> project color) when
the row isn't in the $sessionColorById map, which is keyed over $sessions
only.
Builds on the skipped-clarify card so it holds up beyond the timeout case:
- Extract a shared `ChoiceButton` (letter badge + label + row chrome) used
by both the live pending card and the settled skip card. The two blocks
had drifted into duplicated markup; now they can't diverge.
- Fix the hint copy. An empty `user_response` is emitted for BOTH a
server-side timeout AND a manual Skip (tools/clarify_tool.py) — there is
no field on the result that tells them apart — so asserting "This question
timed out" was wrong half the time. Neutral wording ("This prompt is no
longer waiting…") is correct for either, and the recover-your-answer path
now also helps someone who mis-clicked Skip. Updated en/ja/zh/zh-hant.
No behavior change to the live prompt or the follow-up-message flow.
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
When a clarify prompt times out, the settled card collapsed to just
'Skipped' — the options were unrecoverable (the args carry them, the
renderer dropped them) and there was no way to answer late.
The skipped card now:
- renders the original choices, letter-badged like the live card
- clicking one drafts a quoted follow-up ('Re: "<question>" — my
answer: <choice>') into the composer via the insert bus. Enter sends
it; if the agent is mid-turn it queues like any other prompt.
- a hint line explains the question timed out and what picking does
No retroactive resolution of the expired request: the tool already
returned empty and the turn moved on — injecting into past context
would break prompt-cache and role-alternation invariants, and
clarify.respond on an expired id hard-errors (#56558). The follow-up
message path needs no backend change and works against old backends.
Answered clarifies and free-text (no-choice) skips are unchanged.
Interim UX for #44845 (durable ID-addressable clarify decisions).
The bundled @vscode/codicons font has no `git-fork` glyph (only
`git-fork-private`), so the Branch menu item rendered blank. Use
`repo-forked`, the actual fork icon, to match the inline GitFork action.
The row/tab context-menu 'Branch from here' item used the git-branch
codicon while the inline message action uses a GitFork icon. Switch the
menu item to the git-fork codicon so branching looks consistent across
surfaces.
Branching/forking a chat replaced the primary chat (setActiveSessionId +
setSelectedStoredSessionId + navigate). Instead, open the branch as its own
session tile tab in the center zone and reveal it, leaving the parent chat
exactly where it is — mirroring openNewSessionTile. All branch entry points
(message GitFork button, /branch and /fork, sidebar Branch, tile Branch) go
through forkBranch, so this covers every surface.
Update the gateway's live turn projection after an accepted correction so a warm session resume does not add the stale original prompt beside the persisted correction.
Cover the inference-time correction path end to end and assert both redirect entry points refresh the live user text.
* test(desktop): cover correction resume without duplicate prompts
Exercise a live composer correction, switch away and back before the
response settles, and assert both user turns retain their order and occur
exactly once.
* test(desktop): cover correction warm resume during tool run
Exercise a correction accepted at a foreground-tool boundary, a switch through a persisted session, and the warm resume back. Assert the original prompt and correction remain singular and ordered.
Model settings was the only page that kept its shape while loading; the rest
flashed a centered spinner (LoadingState) or empty placeholders. Standardize on
skeletons that mirror the settings rhythm.
- Add shared SectionHeadingSkeleton / ListRowSkeleton / SettingsSkeleton to
settings/primitives.tsx (mirror SectionHeading + ListRow).
- Convert keys, providers, sessions, gateway, custom-endpoints, and config
(non-model) from LoadingState to SettingsSkeleton; remove now-dead LoadingState.
- billing: BillingSkeleton (summary cards + sections) on first load instead of
"—" placeholder cards.
- pet: skeleton grid on first load instead of a premature "unreachable" message.