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.
Treat HERMES_DESKTOP_HERMES as an authoritative deployment override.
This keeps the Nix desktop package on its matching immutable Hermes CLI
instead of falling through to install.sh when a best-effort version probe
fails or times out.
Add components/ui/progress.tsx — one rounded track + animated fill that owns
role="progressbar" + aria. Migrate the hand-rolled bars (updates overlay,
onboarding, install overlay, billing usage, pet hatch) onto it.
- Pet's bar was never a color variant (--primary and --ui-accent are the same
brand color); its only real difference is the sliding indeterminate, now an
`animated` prop. Color stays a normal `fillClassName` override.
- Billing usage now uses the plain primitive — dropped the bespoke dither
track, inset shadow, and danger nub; tone rides `destructive`/`fillClassName`.
- Drop the redundant emoji "no saved card" row description; the page-level
warn banner is the single explainer.
- Rename pet CSS .pet-progress* -> generic .progress-slide.
* fix(desktop): show steer glyph in busy composer
Restore the steering-wheel glyph when a typed draft redirects a live turn.
Add a real Electron E2E regression test for stop, steer, attachment queueing,
and a paused queue after Stop.
* feat(desktop): add queue action beside steer
Restore an explicit queue action in the former steering slot while a typed
correction is ready. Keep the primary steering-wheel action for redirecting
the active turn.
* fix(desktop): retain dictation beside queued drafts
Keep the dictation microphone visible when a typed busy draft exposes the
separate queue action. Cover the combined controls in the real Electron flow.
* fix(desktop): place queue beside the busy action
Keep the Queue message control after the read-aloud toggle and directly before
the shared Stop/Steer action. Cover the rendered control order in Electron E2E.
* fix(desktop): refresh composer branch after worktree creation
Route new worktree sessions through the shared workspace target handoff so
composer git status follows the created worktree instead of remaining on the
main checkout. Add an Electron E2E regression test for Ctrl+Shift+B.
* fix(desktop): fixme flaky test
On a billing wall, raise a sticky, billing-specific toast (never the generic
error toast) and a persistent in-composer banner for the active session — both
with one recovery action: Nous → in-app Settings → Billing, other providers →
their billing page (deep-linked). The banner reuses the shared StatusRow chrome
(no bordered alert, Codicon glyph, shared buttons), and the composer stays usable
so slash commands keep working.
Detect a billing wall once (agent/error_classifier → FailoverReason.billing) and
map it to a recovery link + label in one place, then carry that structured
BillingBlock to every surface instead of re-parsing free-form error text per
surface.
- agent/billing_links.py: provider-agnostic slug/host → (label, billing URL)
table (single source of truth), Nous-aware (is_nous routes to the in-app flow);
unknown providers degrade to a readable label with no invented URL.
- conversation_loop: both billing exit paths return a billing_block through one
helper; the guidance message carries the derived URL for every provider.
- gateway forwards billing_block on message.complete (it was dropped).
- @hermes/shared: BillingBlock type shared by desktop + TUI.
* test(desktop): e2e test for interim assistant message preservation (#65919)
Adds a Playwright E2E test that reproduces the fix from PR #65919 across
all three layers (agent core → tui_gateway → desktop renderer). The mock
inference server is upgraded with a multi-turn scripted response that
exercises several interleaved patterns:
1. text + tool_call → should produce an interim message
2. text + tool_call → another interim message
3. no text + tool_call → NO interim (no visible text alongside tools)
4. text + tool_call → another interim message
5. final answer (stop) → message.complete, different from all interims
Two describe blocks exercise display.interim_assistant_messages both on
(default) and off:
- ON: all interim texts + the final answer visible in the transcript
- OFF: only the final answer visible, all interim texts wiped
Also fixes a footgun: test:e2e now runs `npm run build` as a pretest
hook so the renderer dist/ is always fresh. Previously, running
`npx playwright test` locally would silently load a stale dist/ that
predated renderer fixes — the python backend ran from source (had the
fix) but the renderer was frozen in an old bundle. CI already built
fresh, so the explicit build step there is removed to avoid duplication.
* test(desktop): e2e sidebar states — background dot, subagent, cross-session
Add sidebar-states.spec.ts with three E2E tests exercising the desktop
sidebar's session dot states driven by real gateway events:
1. Background process dot appears during a terminal(background=true)
call and disappears after auto-dismiss; subagent (delegate_task)
runs concurrently; final answer is visible in the transcript.
2. Background dot remains visible while a subagent runs concurrently
(longer sleep 5 background process so the dot is catchable).
3. Cross-session dot transition: start a turn with a background process,
wait for the turn to complete, open a new session, then verify the
original session's dot transitions from 'background running' to
'finished — unread' when the background process exits.
The mock server gains SIDEBAR_SCRIPT and SIDEBAR_CROSS_SCRIPT trigger
keywords that return tool_calls for terminal(background=true) and
delegate_task — the agent executes these for real (real background
process, real subagent), so the tests assert against genuine gateway
events rather than mocked UI state.
Verified: 3 passed (1.2m) under cage headless wlroots.
* test(desktop): e2e tests for tile-unread bug (tab passes, split fails)
Two scenarios for the tile-unread bug where a session that finishes
while visible on-screen gets the green 'finished unread' dot even
though the user is looking right at it.
The unread check in handleTransition (session-states.ts:174) only
compares against $selectedStoredSessionId and ignores $sessionTiles,
so a session visible in a tile gets marked unread even though it's
on screen.
1. TAB (hidden, PASSES): ⌃-click opens the session as a stacked tab
that is NOT visible on screen. The unread dot IS correct here —
the user isn't looking at it.
2. SPLIT (visible, FAILS): drag the session row to the workspace's
right edge to create a side-by-side split tile. Both sessions are
visible on screen. The unread dot is WRONG — the session is visible
in the split tile, so it should not be marked 'unread'. This test
is RED until the fix lands.
Also adds explicit page.screenshot() calls at key assertion points in
sidebar-states.spec.ts so the trace viewer has full-res captures of the
sidebar dot states during the test.
* test(desktop): cover compression and queued stop lifecycle
Add real desktop E2E coverage for session compression continuation and
queue parking after an explicit Stop. Extend the mock server with a
blocking scripted turn and submitted-prompt assertions.
* test(desktop): cover busy composer submit routing
Replace the invalid queued-stop E2E scenario: plain text redirects a busy
turn rather than entering the queue. Add focused submit-routing coverage for
plain text, slash commands, attachments, explicit Stop, and idle submission.
markVoicePlaybackInterrupted() / takeVoicePlaybackInterrupted() mirror
the backend latch in the renderer (the barge happens client-side, where
the audio plays). VAD barges and typing over playback mark it; the next
prompt.submit carries interrupted:true, which the TUI gateway latches
into the model note.
/api/audio/speak-stream WebSocket: one socket + one Web Audio clock per
reply. The renderer feeds raw LLM deltas as they arrive; the server
cuts sentences with the shared chunker and streams int16 PCM back while
generation continues — speech overlaps generation with no per-sentence
connection or synthesis gaps. Falls back to the POST data-URL path for
old backends / non-chunked providers.
Barge-in runs a MediaRecorder on the monitor's stream the whole time
playback is live (rotated while quiet to bound pre-roll); talking over
the agent cuts playback and the complete utterance goes straight to
transcription and submit. /api/audio/transcribe returns 200/"" for
no-speech results so quiet turns re-listen instead of toasting a 400.
Keep a live session projection from adding its user turn when the latest
persisted row already represents that same inflight prompt. Add real Electron
coverage for fast and cold resume with idle and background-inference sessions.
Post-merge review polish of #68722 + #68761:
- Usage: drop the manual refresh + 'Updated Xm ago'; billing queries now
refetchInterval-poll while the page is mounted, like every other data view.
- No card: lead the page with a warn notice naming the blocker + 'Add card ↗',
so the silently-disabled buy/auto-refill controls have an obvious cause.
- Layout: unify on a shared SettingsCard/SettingsSection primitive; collapse the
three floating one-row sections (Payment / One-time top-up / Automatic refill)
into a single divide-y 'Payment & credits' card.
- Dev fixture switcher: relabel as a wrench + 'preview' dashed control so it
reads as the DEV-only tool it is (compiled out of production).
Removed formatUsageUpdatedAgo/oldestUpdatedAt/UsageRefreshRow + their tests;
added no-card-notice and no-manual-refresh tests. vitest 107 green, tsc + eslint clean.