A message typed while a turn streamed rendered ABOVE assistant output the
user had already watched arrive (#73793), and the retired
insert-before-the-active-reply fallback could splice the bubble mid-thread
— halfway up the chat — when the stream id was missing or stale (#83151).
Fix the class at every path that assigns a transcript position to a
mid-turn user message:
- New shared appendMidTurnUserMessage (rewind.ts): seal the live stream
bubble in place (interim), append the correction at the live tail, and
clear streamId so post-redirect deltas seed a fresh bubble BELOW the
correction. Used by both the primary composer redirect path
(use-prompt-actions) and the session-tile steer path
(session-tile-actions), replacing the insert-before splice and its
last-assistant mid-thread fallback.
- appendLiveSessionProjection now projects the resume/reload turn in
arrival order (prompt → streamed output → correction → post-redirect
output) instead of prompt → corrections → reply, so the projection
agrees with the live transcript and messages no longer jump upward on
reconnect. With the gateway's new correction_offsets the flat dump is
split at each accepted-correction boundary; without offsets the
corrections follow the projected reply.
- tui_gateway/server.py records correction_offsets (assistant text length
at each accepted correction) on the inflight turn and carries them in
_inflight_snapshot, only when complete, so resume can rebuild true
arrival order. Older gateways/clients degrade cleanly.
- preserveLocalPendingTurnMessages and the projection's latest-user-run
matcher now treat a live-tail assistant row between the prompt and its
correction as part of the same turn's run, so arrival-ordered runs
survive refreshes without dropping the prompt.
Fixes#73793. Fixes#83151.
Completes the sidebar order/visibility class on top of the three salvaged
contributor commits:
- mergeSessionPage (#47203): interleave survivors against the
title-preserving merged rows using the backend's effective-recency key
(last_active with a started_at fallback), tie-preferring survivors so
keep-set rows with no timestamps retain the old prepend contract.
- sidebar order helpers (#73314): dedupe live ids as well as persisted ids
in reconcileFreshFirst/reconcileOrderIds/orderByIds so the shared-git-root
flatMap path can neither render one repo once per project nor write the
duplicates back into localStorage (the persisted feedback loop).
- Pinned section (#85969): resolvePinnedSessions falls back to the server
`pinned` flag when the localStorage pin set is cold or clobbered, so a
backend-pinned row is never simultaneously filtered out of every list and
absent from the Pinned section (the "session vanishes entirely" state).
session-pin-sync then adopts the pin locally on its next reconcile.
Regression tests cover survivor interleaving with optimistic bumps and
started_at fallback, duplicate live/persisted id dedup, and pin resolution
fallback (cold cache, lineage-root pins, undefined flag on old backends).
The desktop sidebar persists repo/lane order in localStorage
(hermes.desktop.workspaceParentOrder / workspaceOrder). If that saved
list ever contains the same id twice, orderByIds() pushes the matching
item once per occurrence, rendering the same repo header twice inside a
project. reconcileFreshFirst() then preserves the duplicates, so the
corruption self-perpetuates across restarts and storage clears.
Verified on a live install: the persisted workspaceParentOrder contained
the same repo path at two positions, the backend project tree was clean,
and the duplicated header matched the duplicated id.
Treat persisted UI order as untrusted input: orderByIds() now skips ids
it has already emitted, and reconcileFreshFirst() dedupes the retained
tail so the next persist writes a clean list (self-healing).
`refreshSessions` swaps the session page into `$sessions` only when
`sameCronSignature` reports a change, and that signature compared row
content — id, lineage root, title, source, profile, preview,
message_count, last_active, ended_at — but not row state. A page whose
only delta was `pinned` was judged identical and discarded, so the row
cached in the atom kept its old flag indefinitely. An idle conversation
never moves any of the compared fields again, which is exactly the kind
a user goes and unpins.
`session-pin-sync` treats that row as authoritative. Its write guard
(daeedf67c) is released by a page that CONFIRMS the value it wrote, and
falls back to letting the server win once WRITE_GUARD_MS elapses with no
confirmation. Because the confirming page was filtered out one layer up,
the fallback was the only branch that ever ran: ~10s after an unpin the
next reconcile read the frozen `pinned: true` row and called
pinSession() again. Adoption marks the id `mirrored`, so the push pass
never corrected the backend either — the local pin set and
sessions.pinned drifted apart permanently, which is why four of five
pins rendered in the sidebar read pinned=0 in state.db.
Compare both flags so a pin-only page reaches the atom. That restores
the guard's confirm path and makes WRITE_GUARD_MS a backstop again
rather than the load-bearing branch. `archived` is included for the same
reason: it is row state a consumer reads. Neither flag moves outside a
deliberate user action, so the churn the gate exists to prevent is
unaffected.
The existing `releases the guard once a page confirms the written value`
test passes on main because it hands `$sessions` the confirming page
directly — the gap was in the pipeline that decides whether such a page
is ever delivered.
Fixes#76919
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When multiple sessions are active/settled simultaneously, survivors
(sessions the server omitted from the fresh page) were prepended as a
block in their old relative order from the previous $sessions array.
This caused recently-interacted sessions to appear below older ones.
Now survivors are sorted by last_active descending and merged into the
incoming array at the correct position using a two-pointer merge,
so the sidebar always reflects true recency.
Fixes#47203
Regression coverage for #68634. The indicator family mounts only on the
thread's any-role tail (ba756333): a running bubble that is not the tail
stays silent, even when only a user or system row trails it, and the
optimistic-placeholder flow renders exactly one status row, the
placeholder's own.
Mutation-checked: relaxing the mount gate to a last-assistant walk fails
the two silence cases, and removing the gate fails all five.
Clarify prompts share the same emitted-while-detached failure class the
pending-approval replay fixed: `clarify.request` rides `_block()`'s pending
registry, so a client whose transport was down when the event fired never
sees the question and the agent thread stays parked until timeout.
Widen the resume snapshot the same way:
- tui_gateway/server.py: `_live_session_payload` now carries
`pending_clarify` — a read-only snapshot of the clarify prompt still
blocking the session, scoped to the owning runtime sid. The registry stays
authoritative; the embedded request_id resolves via clarify.respond.
- Desktop resume paths (`use-session-actions`) restore the parked clarify
into the clarify store (multi_select preserved) and flag needsInput,
mirroring restorePendingApproval on both the activate and resume paths.
- pending_approval replay now also forwards the queue-injected request_id so
the restored prompt responds with exact-request correlation.
- Tests: server-side replay + scoping test; harmonized the #82087 replay
test with the request_id `_ApprovalEntry` now injects.
Reveal a blocking clarify card by re-arming the existing thread bottom-scroll bridge after the request row is hydrated. Keep background-session prompts isolated to their needs-input indicator.
Refs #53666.
Correlate approval requests, reject stale responses, replay pending approvals after reconnect or session resume, and preserve fail-closed timeout behavior.
Follow-ups on top of the two salvaged commits:
- widen the #83855 recently-interrupted cooldown to the session-tile
interrupt path (use-session-tile-delegate.interruptSession) — same race
class, sibling call site: a tile Stop also clears busy before the
gateway settles, so a quick tile edit/resend raced 4009 session busy.
The recovered runtime id is marked too.
- regression test for the tile cooldown.
- refresh three #65328 ownership-proof assertions to tolerate the
omit_messages flag main now sends on session.resume (toMatchObject).
- eslint import-order fix in utils.test.ts.
Fail closed on missing ownership cache entries and prove runtime ownership
forward+reverse against runtimeIdByStoredSessionIdRef before prompt.submit.
Thread the ownership cache through main wiring and session-tile submit.
Adds regression tests for forward mismatch, reverse-only proof, positive
map control, and cache-miss resume.
Closes#65328.
Stop clears frontend busy immediately while the gateway may still wind
down. Edit/restore then passed interruptFirst=false and raced 4009
session busy. Keep a short per-session cooldown after cancel so rewind
still interrupt-first, and expire the submit-in-flight lock so a hung
submit cannot block the session forever.
Fixes#83855
Co-authored-by: Olympusbuildz <Olympus.roots@outlook.com>
Signed-off-by: Olympusbuildz <Olympus.roots@outlook.com>
Two sibling sites still treated only 'completed'/'failed' as terminal:
- delegate-model.ts settled result rows as 'completed' for ANY status other
than 'failed', so a delegate result row with status 'timeout' or 'error'
(the statuses tools/delegate_tool.py actually emits on child timeout or
crash) rendered behind a green check. Settled rows now map ok/completed
to completed and everything else to failed.
- subagents.ts asStatus accepted a literal 'queued' payload status even on
a subagent.complete event, leaving the row active forever. The fail-closed
branch now runs before the queued fallback, so completion events always
settle.
Folded-in coverage from PR #80045 (gannotti, #80018): after a terminal
subagent.complete, a stray late 'running' progress event must not restart
the spinner — the upsert guard keeps the settled failed status.
Folded-in coverage from PR #85995 (smause): a subagent.complete event whose
payload still says 'running' or 'queued' must settle the row as failed —
the completion event itself is the source of truth that the child is done.
Review feedback: prev?.summary could shadow the 'Timed out after Xs'
reason when a live event had populated it. timeoutSummary() now wins for
raw timeout status; add coverage for the missing-duration placeholder.
Follow-up to the #73728 normalization fix (supersedes the event-agnostic
fallback the maintainers flagged as incomplete):
- subagent.complete is terminal by definition — an unrecognized status on
it now renders as 'failed' instead of falling through to 'running',
which would recreate the immortal false-active row for any future
backend status (the keep_open request on #73859).
- Live events keep the lenient 'running' fallback.
- Synthesize a 'Timed out after Xs' summary from duration_seconds when
the backend completes with status 'timeout' and no summary, so the
failed row explains itself.
- Tests: timeout reason synthesis + pruning, event-aware fail-closed vs
lenient live fallback (13 total).
The backend emits terminal statuses including 'timeout' and 'error' in
subagent.complete payloads, but asStatus() only recognised 'completed',
'failed', 'interrupted', and 'queued'. Unrecognised values fell through
to 'running', making timed-out subagents immortal in the active status
stack.
Fix: map timeout/error to 'failed', cancelled/canceled to 'interrupted'.
Nonterminal unknown statuses still default to 'running' for forward
compatibility.
Fixes#73728
Re-run the foreground session-list refresh whenever the active gateway profile changes, preventing rows from the previously selected profile from persisting in the sidebar.
session.activate's persisted-transcript refresh reconciled unconditionally
against getLatestSessionMessages, so a transient empty REST page (e.g. a
backend respawn racing its own state.db read after a wake/reconnect) wiped
a transcript the activate response had just restored. Guard it the same way
the activate payload itself already is guarded a few lines above: an empty
authoritative page never overrides a non-empty cached transcript.
Sweeper review on #76583: dropping interimBoundaryPending from the
previewed settle path let a previewed final arriving after a
message.start reset OVERWRITE a distinct interim instead of appending
(interim('old') → message.start → complete({response_previewed: true,
text: 'new'}) destroyed 'old'). responsePreviewed may rewrite the final
with no prefix guarantee, so it must stay flag-gated; only
finalContinuesInterim (prefix-either-way continuity, which can only hold
for the same message) settles flag-free. New test: distinct previewed
final after a reset appends its own bubble. Production ordering cited in
the test: compaction-resume events exclude message.start
(gateway-event.ts), and the TUI gateway emits message.complete before
goal-followup starts (tui_gateway/server.py).
74/74 use-message-stream tests pass.
completeAssistantMessage merged a turn's final text onto its sealed interim
bubble only when the session's volatile interimBoundaryPending flag was still
true. A subsequent message.start (chained turn, follow-up, or mid-turn
compaction) resets that flag to false; when it landed between the same turn's
message.interim and message.complete, the flag-based gate fell through and the
UI appended a duplicate bubble.
Key the merge on the message's OWN durable interim state instead of the
session flag. finalContinuesInterim already requires existing.interim plus
prefix continuity, so distinct replies (which don't continue the interim) are
still appended as their own bubble.
Adds a regression test: interim + message.start + completing final that
continues the interim must yield one bubble, not two.
A still-pending assistant stream row (id `assistant-stream-*`) whose reply
the authoritative transcript already committed used to fall through to
`preserved.push` when ordinal pairing missed it — the commit shifted the
row's ordinal under compaction/history rewrites, so `nextByRoleOrdinal`
returned nothing and the local copy was appended to the tail, rendering the
same answer twice (reported as A B C D E C D tail duplication).
The #70209 guard only covers SETTLED local rows (`pending !== true`);
pending rows were unprotected. Match pending rows against SETTLED
authoritative rows before appending:
- identical answer text -> authoritative already carries it
- authoritative extends local text -> authoritative is the settled final
version of the still-streaming local copy
- local extends authoritative text -> replace the committed row with the
richer local body instead of appending
Live projection shells (still-pending candidates) never match, so the
traces-only local row keeps replacing the empty shell.
Persist backend ownership for reliable cleanup, park inactive panes, and
evict unreferenced transcripts so Desktop stays responsive over long sessions.
💘 Generated with Crush
Assisted-by: Crush:gpt-5.6
Use the available HUD window height for non-empty transcript scrollback instead of a fixed glance-band cap. This makes the corner resize affordance reveal additional conversation content while preserving the compact empty HUD state.
Keep the native HUD window mouse-solid while focus is inside the rich composer. On Windows this prevents click-through from reactivating the app beneath the HUD, stealing the caret, and collapsing the transcript.
* feat(desktop): per-profile scope selector in the Capabilities view
Adds a 'Configuring:' profile selector above the Tools and MCP tabs in the
Capabilities (Skills) view, so a user can configure ANY profile's toolsets
and MCP servers without switching the whole app into that profile.
- hermes.ts: every capability fetcher (getToolsets, setToolsetEnabled,
getToolsetConfig/Models, selectToolsetModel/Provider, runToolsetPostSetup,
getMcpCatalog, installMcpCatalogEntry, testMcpServer, saveMcpServers,
auth/oauth flow, setEnvVar/deleteEnvVar/revealEnvVar, startOAuthLogin/
pollOAuthSession, getActionStatus, getHermesConfigRecord) takes an optional
trailing profile? that forwards to profileScoped(profile). Omitting it
preserves exact app-wide behavior (profileScoped(undefined) → _apiProfile).
- use-config-record.ts: hermesConfigKey(profile)/useHermesConfigRecord(profile)/
hermesConfigCacheWriter(profile) — per-profile RQ keys (scope-in-key).
- toolset-config-panel.tsx + mcp-tab.tsx: thread profile through every fetch
and their nested children (EnvVarField, PostSetupRunner, ModelCatalogPicker),
keyed/remounted per selected profile so switching never shows stale state.
- skills/index.tsx: the selector (seeded from profiles.list, default→'Hermes',
shown only with >1 profile), defaulting to ; toolsets
query + toggles keyed and scoped to the selection; McpTab/ToolsetDetail
remounted per scope.
- i18n: skills.configuringProfile (en + zh; others fall back).
When the selected profile equals the active one (the default), behavior is
identical to before — the selector is a pure override layered on top.
Tests: index.test.tsx — new case asserts picking a non-active profile in the
selector refetches toolsets scoped to that profile; existing single-profile
cases still pass (selector hidden with one profile). 5/5. Full-project tsc clean.
* Fix CI: command-palette getHermesConfigRecord call, panel test mock, lint
- command-palette/index.tsx: getHermesConfigRecord now takes an optional
profile; passing the bare fn as queryFn fed it react-query's context object
(TS2769 + mcp_servers on {}). Wrap in an arrow.
- toolset-config-panel.test.tsx: use-config-record now imports normalizeProfileKey
from @/store/profile, which calls setApiRequestProfile at module-init; the
full-replacement @/hermes mock must provide it (+ getApiRequestProfile).
- index.test.tsx selector test: stub Element.prototype.scrollIntoView (Radix
Select calls it on open; jsdom lacks it).
- toolset-config-panel.tsx: PostSetupRunner useCallback missing 'profile' dep
(stale-closure correctness); jsx-prop sort order.
Verified in a full-dep checkout: tsc 0 errors, eslint clean, panel 28/28 + skills 5/5.
---------
Co-authored-by: Teknium <teknium1@users.noreply.github.com>
The statusbar gauge painted only what the backend reported as measured
occupancy, which a session has none of until a turn runs in this process.
Turning the gauge on mid-conversation, or resuming a chat, therefore showed
nothing until the next message.
Fetch session.context_breakdown as soon as the gauge is on screen instead of
when its popover opens. It is the same read-only estimate the popover already
used (chars/4 over the live prompt, tools and transcript — no provider call),
and it reports the measured figure once the backend has one. The popover
becomes presentational and reads the gauge s merged usage, so the bar and the
panel cannot disagree.
* fix(desktop): the main agent's model pick persists as the profile default
Reported: the default bot switches to the OpenAI API account instead of
the user's subscription, and doesn't retain the previous selection.
Root cause: the composer model picker always sent the switch as
--session scope, even for the PRIMARY profile's main agent. So the pick
never wrote config.yaml model.provider — and with model.provider unset,
resolve_provider('auto') falls through to a leftover OPENAI_API_KEY env
var and picks OpenAI/OpenRouter. The subscription the user selected was
only ever a per-session override that evaporated on the next session.
Fix: when the pick targets the primary profile's main agent
(touchesPrimary), send --global so it persists to config.yaml
(model.default + model.provider) via the existing model-switch persist
path. A SET model.provider already outranks the OPENAI_API_KEY env var
in resolve_provider (tier 2 vs tier 3), so the main agent now keeps the
chosen provider across restarts. Secondary chat tiles stay --session so
picking a model in one chat never rewrites the profile default (the
cross-session-contamination guard the old comment protected).
No change to resolve_provider's priority chain, so #29285 (an explicit
env key beating a STALE oauth login) is untouched — we simply make the
user's explicit main-agent selection the config default it always
should have been.
* MoA presets stay session-scoped; update tests for primary-persist intent
Fix CI (ui shard 3of3): the primary main-agent pick now persists via
--global, but MoA (mixture-of-agents) presets must NOT — a transient
orchestration choice can't become the global gateway default. Exclude
provider==='moa' from the persist path (stays --session). Update the
primary-picker test to assert --global (the new intent) and keep the
MoA + secondary-tile tests asserting --session (the guards that prove
the narrowing). 19/19 green locally.