Commit Graph

5205 Commits

Author SHA1 Message Date
teknium1
acccd54c67 fix(desktop): restore the unsent composer draft when a session is gone
When a Desktop tab references a session id that exists in no profile DB
(deleted elsewhere, or a stale id after a profile rename / wiped backend),
the resume path already drops the window to a fresh draft without toasting
or hot-looping (62af32efe7, bounded by goneSessionVerdict). But the text
the user had typed into that session stayed stashed under the dead key in
`hermes:composer-drafts:v3` — not lost, yet invisible, because nothing ever
opens that key again. From the user's side the message just vanished.

The gone verdict now announces the dead key (`announceGoneSessionDraft`),
and the composer's swap onto the fresh-draft scope consumes it once
(`adoptGoneSessionDraft`): the stash moves into the new-chat bucket, the
composer is seeded with it, and an inline "Restored your unsent message"
strip with Undo appears above the input. Undo returns the text to the dead
key while it is unchanged (still recoverable by the same path); once edited
it only dismisses. Keyed on the one-shot announcement rather than on the
composer observing an id→fresh transition, because the composer remounts
across the drop (a loading route mounts no composer). A fresh draft that
already has text is never clobbered; an empty stash shows nothing.

Offer, don't hijack (apps/desktop/AGENTS.md): no navigation beyond the drop
that already happened, no focus move, no toast.

Live (headless Electron over CDP, worktree backend): type into a live
session → delete its row + restart the backend → reload at the dead id.
Before: route drops to #/, composer empty, text only in localStorage under
the dead key. After: same drop, composer holds the text, strip + Undo shown;
Undo empties the composer and puts the text back under the key; a second
open of the dead id shows no strip; a dead id with no stash shows nothing.

Fixes #111868
2026-09-18 20:56:12 -07:00
teknium1
0c3ebefb5e fix(desktop): /reasoning hide|show gates Thinking blocks immediately
`display.show_reasoning` is a client-side display decision since 40f2368875,
and the Desktop transcript honors it through `$showReasoning` (#115335). The
composer path did not: `/reasoning` was marked `desktop="advanced"` in the
Python slash registry, so the Desktop refused it as "not available", while a
gateway-routed `/reasoning hide` would only have written config.yaml and left
the atom waiting for the next config refresh.

Route `/reasoning` to the gateway's `config.set key=reasoning` — the Ink TUI's
path (ui-tui/src/app/slash/commands/session.ts) — and mirror the answer:
`hide`/`show` flip `$showReasoning` on the spot, effort levels leave the gate
alone and get session scope (a throwaway slash-worker CLI could never pin the
live session's effort). No arg reports the current effort and display, like
Ink. Drops the registry's `advanced` marker and regenerates the desktop dump.

Live (real `hermes serve`, scratch HERMES_HOME, real WebSocket): config.set
key=reasoning value=hide -> {value: hide}, config.yaml show_reasoning=False;
show -> True; high -> effort only, display untouched.

Fixes #111761
2026-09-18 20:55:49 -07:00
teknium1
d99226015d fix(desktop): count the head entries the group-chat mirror drops
The ui_meta mirror of a room is head-trimmed to 16 messages and then to
the 48 KB envelope, silently. It is the only on-disk copy of the room, so
an agent that consults it to check what the user said reads a bounded
window with no sign that it is bounded (#114341, secondary). Stamp
`omitted: N` on the projected room whenever entries fall off the head,
recomputed after every byte-budget shift so the envelope bound stays
honest, and carry the larger writer's count through the publish merge as
a lower bound. Local room state never receives the field: the merge-back
into $groupChats builds from explicit fields.
2026-09-18 20:37:28 -07:00
686f6c61
d32dacf855 fix(desktop): mark truncated group-chat sync payloads
The ui_meta projection sliced messages at 1200 chars with no
signal that the body continued. Stamp truncated and keep the
marker inside the existing per-message budget.
2026-09-18 20:37:28 -07:00
teknium1
0a3fce8846 test(desktop): pin the omitted-head marker on the real group turn path
The salvaged tests exercised the formatGroupDeltaLines helper in
isolation, so a call-site regression (a bare slice reappearing in
prepareGroupRoundMember) would have stayed green. Drive the room through
runGroupChatRounds instead and assert on the prompt the gateway actually
received: over-long delta -> marker naming the dropped count and the
dropped head absent; delta that fits -> no marker. The helper-level pair
is dropped so the file keeps two invariant tests for this fix.
2026-09-18 20:37:28 -07:00
liuhao1024
c91d955ff7 fix(desktop): mark the truncated head of a bot group-chat turn delta 2026-09-18 20:37:28 -07:00
teknium1
55c92fa32a fix(desktop): every Custom Endpoints completion is dropped once its profile view unmounts
The salvaged guard covered only the save path. Validate, activate and delete completions
from a profile-A instance could still set state, fire onConfigSaved / onMainModelChanged
and toast after SettingsView remounted the panel under profile B — the identical stale-
completion race, three more doors. The same `mounted` ref now gates all four handlers
and `refresh()`.

Test: the salvaged vitest file now partially mocks `@/hermes` (the profile-scope note
subscribes to `setApiRequestProfile` at import time) and stubs `ActiveProfileNote`.

Closes #69451 (renderer `profileScoped()` forwarding, backend `_config_profile_scope`
and the profile-keyed remount landed on main earlier; this closes the last atom).
Salvage of #69452.
2026-09-18 15:12:27 -07:00
Sun Haoyuan
0eb0a6d78b fix(desktop): drop stale custom endpoint saves 2026-09-18 15:12:27 -07:00
kshitijk4poor
fc8b671cf7 refactor(desktop): say why the standalone reasoning gate exists; sharpen the toggle test
The docs line implied a manual refresh step — open chats update as soon as
the setting saves. The ungrouped `Reasoning` gate reads as dead next to the
group's, so name the path that reaches it. The on/off assertions now fail
with the element, not `expected false to be true`.
2026-09-19 01:47:29 +05:30
kshitijk4poor
ad12cbbd55 docs(desktop): document the Reasoning Blocks toggle; narrow its config type
The desktop guide listed every other Settings toggle but not this one, and
the key's type only needs the two shapes config.yaml can hold (boolean, or a
quoted string), matching `display.timestamps` beside it.
2026-09-19 01:47:29 +05:30
kshitijk4poor
d12736c959 refactor(desktop): mirror display.show_reasoning the way display.timestamps is mirrored
Collapse the string-coercion table to the one case config.yaml can actually
produce (a quoted "false"), matching setDisplayTimestampsFromConfig, and trim
the config-hook coverage to the single mirror/default contract. The renderer
test (grouped + standalone parts, on and off) is unchanged apart from
formatting.
2026-09-19 01:47:29 +05:30
r266-tech
f12c3a1631 fix(desktop): honor display.show_reasoning in the message renderer
The desktop Settings → Chat "Reasoning Blocks" toggle (display.show_reasoning)
is persisted to config.yaml but the React renderer never read it, so toggling
it had no effect — reasoning blocks were always shown regardless (issue #49664).

Wire the renderer to the setting:
- add a $showReasoning nanostore atom + setShowReasoning setter
- refreshHermesConfig populates it from Boolean(config.display?.show_reasoning),
  read from the same /api/config the Settings toggle uses, so an unset key
  resolves to false (matching the toggle and the gateway/CLI default)
- gate ReasoningAccordionGroup (the "Thinking" disclosure) and ReasoningTextPart
  on the atom; the existing hasContent guard is preserved
- add show_reasoning?: boolean to the HermesConfig display type
- store unit tests for the atom/setter

The atom defaults to true so reasoning keeps rendering if the (nice-to-have)
config read fails; the config refresh then resolves the real value.

Fixes #49664

(cherry picked from commit e199fadde81ffb8af70f49b9785f597295ac0f48)
2026-09-19 01:47:29 +05:30
kshitijk4poor
5a55979e16 refactor(sidebar): seed pinned rows through _seed_session; mirror the truncation invariant in the TS comment 2026-09-19 01:43:14 +05:30
kshitijk4poor
31ca48512b test(sidebar): pins inside the recency window still report more sessions on disk
One test per truncation flag: the batched /api/profiles/sessions/sidebar
route (real state.db, two pinned among the newest rows, cap 4) and the
desktop's legacy per-slice derivation (3 pinned + 17 unpinned against a cap
of 20). Both red on origin/main, green with the fix. Regression for #81484.
2026-09-19 01:43:14 +05:30
Yongli
e16fd43ca4 fix(desktop): count pinned rows toward sidebar window truncation
The recents window is a LIMIT-cap page by recency plus a back-fill of pins
the page missed. Both truncation flags — the batched /sidebar route and the
legacy per-slice derivation in the app — discounted every pinned row, on the
theory that pins only ever arrive past the LIMIT. Pins inside the window
occupy real slots, so with two pinned rows among the twenty newest the count
came to 18 < 20, `profiles_truncated` read false, and the sidebar never
mounted "Load more": every older session was unreachable (#81484).

Count every row. A list shorter than the cap has nothing past the page for
the back-fill to add, so pins cannot fake a full page there; at worst a list
of exactly cap rows costs one click that resolves to an empty page.

Salvaged from #81490.
2026-09-19 01:43:14 +05:30
kshitijk4poor
b46f2448f7 refactor(desktop): the newest-turn exemption is a boolean, not a count
Nothing ever passed a tail length other than 0 or 1, and a bare `1` at the
call site needed the comment to decode. `exemptNewest` reads at the call
site without it; the doc comment now also says the exempt turn counts toward
`minVisible`.
2026-09-19 01:42:52 +05:30
kshitijk4poor
bdcda7e37e fix(desktop): exempt the newest turn from the budget on a real page only
Review of the salvage: exempting the newest turn during the first-paint
budget made the walk always continue into the previous turn, so a heavy
previous turn landed in the synchronous first-paint commit that
FIRST_PAINT_BUDGET exists to keep small. Gate the exemption the way the turn
floor already is (renderBudget >= paneBudget); the rAF backfill reaches a
real page a frame later and the cut is stable from then on. Also start the
walk at the budgeted end instead of testing every index, and document the
parameter on the function.
2026-09-19 01:42:52 +05:30
kshitijk4poor
5c4b87a371 test(desktop): trim the unbudgeted-tail coverage to two invariants
The completion-time case is the same contract as 'exactly one newest group
is exempt' — a heavy tail never charges the budget whether it is still
streaming or done — so one test pinning the growth and one pinning the
single-group exemption cover it.
2026-09-19 01:42:52 +05:30
Victor Nogueira
c1af9c6494 fix(desktop): keep newest turn outside history budget
(cherry picked from commit de08125d046c56463c42df9365f41d0e83c895fe)
2026-09-19 01:42:52 +05:30
worlldz
65babb3bcb fix(desktop): keep streaming turn out of history budget
(cherry picked from commit eb127502196dcd546a082f86827ea6eca995aa79)
2026-09-19 01:42:52 +05:30
kshitijk4poor
61134dbcde refactor(desktop): state the shell/grabber handle split once, at useSortableBindings
The mechanism is a property of the dnd-kit handle, not of any one row; the
three shells now point at the one explanation instead of repeating it.
2026-09-19 01:34:15 +05:30
kshitijk4poor
15a256293d fix(desktop): gateway/profile group headers take the pointer activator only
Same defect class as the session and project rows: the group header spread
the full dnd-kit handle, so Space on its ⋯ button (which opens a rename
dialog) armed a keyboard reorder and the sensor ate the next Space in the
dialog input. Keep the explicit onPointerDown forwarder — a press on the
label still starts the reorder, which the header test now proves through the
pointer path — and move the keyboard reorder assertion onto the grabber,
where the keyboard activator lives.
2026-09-19 01:34:15 +05:30
kshitijk4poor
5d59a43593 test(desktop): pin that Space on a row control never arms a sidebar reorder drag
Renders the real SidebarSessionRow inside ReorderableList with the sidebar's
own sensor set. Space on the focused ⋯ button must pass through (not
defaultPrevented, no aria-pressed grabber); the grabber itself still starts a
keyboard reorder. Red on origin/main (the shell spread the keyboard
activator), green with the fix. Regression for #83617.
2026-09-19 01:34:15 +05:30
David Metcalfe
7a46819249 fix(desktop): stop the sidebar drag sensor from swallowing Space in text inputs
The session and project row shells spread the FULL dnd-kit handle
({...attributes, ...listeners}) and then forwarded onPointerDown again by
hand. The spread put dnd-kit's keyboard activator (and role/tabIndex) on the
whole row, so Space/Enter on any focused control inside it — the ⋯ menu
button that opens Rename — started a keyboard drag, and an armed
KeyboardSensor then consumed the next Space at window level: the rename
dialog's input dropped the keystroke (#83617).

Drop the spread. The explicit onPointerDown already forwards the pointer
activator, and the grabber keeps the full handle so keyboard reorder stays
accessible.

Salvaged from #83618 (minimal slice of the shell/grabber split).
2026-09-19 01:34:15 +05:30
teknium1
00b7997f47 fix(desktop): label an overdue next run on the Bot Mode routine card
The hermes-bots plugin's Routines card (and its inspector's `Next run` row)
still read `Next: 7 hr. ago` for a `next_run_at` parked past the scheduler
grace — the one Desktop surface the overdue labelling left out (#114309).
The card now switches to `t.cron.overdueSince` and the inspector row to
`Overdue since`, using the same `nextRunOverdueMs` decision as the core cron
panel and sidebar, so paused/completed/disabled jobs keep the plain label.

`nextRunOverdueMs` is exported through `@hermes/plugin-sdk` (the plugin only
imports from the SDK) and its parameter is widened to the optional-field shape
the plugin's `RoutineJob` carries; no plugin i18n key is needed because the
card already renders from core `t.cron`, which every locale resolves.

Test: one card test renders an overdue vs an upcoming job and checks both the
card label and the inspector rows; the fixture's fixed 2026-08-23 next_run_at
is made clock-relative so it never ages into "overdue" on its own.
2026-09-18 12:46:54 -07:00
teknium1
eddf7283e4 fix(cron): label overdue next runs in cron list, dashboard and Desktop; one parser for the instant
`hermes cron list`, the web dashboard Cron page and the Desktop cron panel/sidebar all
rendered a `next_run_at` parked hours in the past as an ordinary upcoming "Next run" —
the only user-visible trace of a scheduler that stopped ticking (#114309). Every
surface now labels a slot past `cron doctor`'s 15-minute grace as overdue (CLI
`Overdue:` row with the lateness, web `Overdue since`, Desktop `Overdue since` label
on the detail panel and sidebar meta) while paused/disabled/completed jobs keep the
plain label because they are not expected to fire.

The CLI's overdue check now parses through `cron.jobs._parse_aware` and `hermes_time.now`
so status/list/doctor and the ticker agree on the instant (mixed offsets, DST folds,
legacy naive stamps read as system-local like the scheduler does), and both the
ordering and the subtraction normalise to UTC: Python compares same-tzinfo datetimes by
wall clock, which is wrong across a DST fold. Status still orders the soonest run by
instant (#113874) and prints the stored stamp.

Tests: the salvaged status tests are trimmed to two invariants (overdue + stale
heartbeat is loud on status AND list; within-grace stays plain on both), the DST-fold
ordering tests freeze the CLI clock as well so their 2026-11 fixtures never start
reading as overdue, and one vitest each pins the web and Desktop helpers.

Co-authored-by: funky-xamarin <30426178+Wenfengcheng@users.noreply.github.com>
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
2026-09-18 12:46:54 -07:00
teknium1
8bbc02a02a test(desktop): move the turn-lease tests to the end of the lifecycle file
Keeps the block off the shared anchor after afterEach that the sibling
prune-redial fix also extends, so the two PRs merge independently.
2026-09-18 12:45:59 -07:00
John Paul Soliva
e1374093aa fix(desktop): never register a turn lease that nothing can release
A routed prompt holds one lease per (route, runtime session) until the turn settles, and for a
streaming turn only a Secondary's own terminal-event listener ends it: releaseTerminalTurnLease
invokes `g.turnLeases.get(key)?.()`, so a lease that is not the mapped one is never called at all.
Two ways a hold ends up in that state, both closed here.

The function already skips the primary profile, and its comment says why: a no-op lease leaves a
phantom key that suppresses the real hold if the route is later re-homed as a secondary. But the
primary profile is not the only route served by the primary socket. retainGatewayForAgent returns a
no-op for a shared-remote collapse and for a build with no registry dialing, and gatewayForProfile
returns one for a shared-primary route; none of them creates a Secondary. A lease registered on any
of those is stored and can never be released — and because the map is keyed per session, it silently
suppresses every later hold on that session, including after the route becomes a real pooled
secondary. The shared-remote probe explicitly expects that transition: it prefers the primary "until
a later probe can prove isolation". The next turn then runs with no hold at all, so the socket is
free to be reaped mid-turn and the gateway interrupts the turn as client_gone, which is the failure
this mechanism exists to prevent.

The duplicate-lease guard is also a check-then-act: it runs before the retain awaits and the map is
written after it, so two submits for one (route, session) can both pass. The loser's hold is then
orphaned — its release is never the mapped one, so activeRequests stays above zero and the pooled
socket can never be reclaimed.

Both are decided by outcome rather than by re-probing: no pooled entry for the scope means nothing
can release the key, and a key already present means someone else owns the turn. Either way the
route is released and the caller gets a no-op.

These are the function's first tests. One takes a lease while the route rides the primary, leaves it
unreleased as a streaming turn would, then makes registry dialing available and asserts the next
turn really holds a socket. The other races two submits across the dial, fires the terminal event
the gateway's own listener would, and asserts the socket is then reclaimable. Each fails without its
own guard.
2026-09-18 12:45:59 -07:00
teknium1
9d5a076c4c refactor(desktop): read OVERLAY_SURFACE directly in composer-focus-keys
The hoist into combo.ts left a BLOCKING_OVERLAY alias behind for its single
use site; drop the alias.
2026-09-18 12:45:30 -07:00
teknium1
a2ed82c88d fix(desktop): keep the message reaction picker open while the pointer moves toward it (#114130)
Clicking the reaction button on an assistant message opened the picker,
but the very next pointer movement over the transcript closed it, so no
emoji could ever be chosen. The group-hover / PopoverAnchor mechanism in
the report is not the cause: the anchor is already kept live while open
(styles.css `[data-state='open']`) and Radix does not close on a hidden
anchor. The picker died to the floating composer's focus-follow: Radix
autofocuses the popover content when it opens, `trackPointer` pulled
that focus back into the pane composer on `pointermove`, and the
popover's `onFocusOutside` dismissed it.

Guard `focusSelectedComposer()` — the chokepoint every focus-follow
branch funnels through — against focus that sits inside an open floating
layer (popover, menu, listbox, dialog, popper wrapper). The selector is
the one `composer-focus-keys` already uses for its overlay gate, hoisted
to the leaf `combo.ts` as `OVERLAY_SURFACE` so both consumers share it.

Tests: focus-follow leaves focus inside a floating layer (with the
plain-control control case), and the assistant reaction picker survives
a pointermove over its message with a registered floating composer. Both
red on origin/main.
2026-09-18 12:45:30 -07:00
teknium1
1879088d3f fix(desktop): localize the session-named prompt title; trim to two invariants
Follow-up to the salvaged #114383 commit: the "<title> — <session>" template
moves out of the dispatcher into the locale bundles as
`notifications.native.approvalTitleNamed` / `inputTitleNamed` (types.ts + all
six locales), so translators own the separator and word order. The
dispatcher picks the key by kind (approval / input) and keeps the caller's
bare title for prompts without a session. Naming order matches the sidebar:
title → preview → short id tail.

Tests trimmed to two invariants: the handler-level approval toast in
server-requests.test.ts and one dispatcher test covering the sibling input
toast, the id-tail fallback and the untouched completion title.

Co-authored-by: JinUltimate1995 <92670540+JinUltimate1995@users.noreply.github.com>
Co-authored-by: Zeus-Deus <github.commits@widow.cc>
2026-09-18 12:45:00 -07:00
finn763
eb7687b995 fix(desktop): name the session in blocking-prompt OS notifications
Three sessions parked on approvals raised three identical "Approval needed"
toasts with no way to tell them apart (#114337), even though the runtime
session id was already passed to the same call. The shared dispatch in
store/native-notifications.ts now resolves that id to the session name
(title, else preview, else a short id tail) and appends it to approval /
input titles: "Approval needed — Fix the flaky test". Every emitter of the
blocking-prompt family routes through it, so the approval toast and the
sibling input.request one both get the label from one place.

Session-scoped attention kinds only: completions stay as they are.
2026-09-18 12:45:00 -07:00
teknium1
ec4cd38d3e fix(desktop): mock the profile-store hermes exports in local-models-settings test
The Local Models page now imports the settings-scope chip, which pulls in
src/store/profile.ts; that module subscribes $activeGatewayProfile ->
setApiRequestProfile at load, so the test module failed with "No
"setApiRequestProfile" export is defined on the @/hermes mock". Add the
two exports the profile store reads, as the store tests already do.
2026-09-18 12:44:32 -07:00
teknium1
3e182f46c3 fix(doctor): flag a non-list custom_providers and legacy list entries with no providers: twin; name the edited profile on Custom Endpoints / Local Models
`hermes doctor` (and the startup config-structure warning) now report a
`custom_providers` value that is not a YAML list — naming the key and the
received type — instead of the runtime silently serving "0 endpoints".
Doctor also warns about every legacy `custom_providers` list entry whose
endpoint URL has no `providers:` twin, with the exact move to make: such an
entry is served by the chat picker (dual-read view) but has no row on the
Custom Endpoints settings page, and the one-shot v11→v12 list migration
(config_migrations._migrate_to_12) never re-fires once the version is past 12.
Warn-only on purpose: re-running the migration would mint `<key>-N`
duplicates for entries that DO have a twin.

Desktop: Custom Endpoints and Local Models send unscoped requests and always
edit the app's active profile; they now print the same "Changes on this page
apply to the “X” profile." note the Model page uses (hidden with one profile).

Part of #114471 (items 2, 5, 6).
2026-09-18 12:44:32 -07:00
teknium1
17c7f73a18 fix(desktop): clamp cascaded instance windows to the work area they land on
instanceWindowBounds added +32/+32 to the live source window unclamped, so a
source docked at the bottom/right edge produced a rect past the screen — the
same off-screen CreateWindow rect this PR fixes for the saved-state path. Run
the cascaded rect through computeWindowOptions with the live displays; with no
matching display the raw cascade is kept.
2026-09-18 12:43:36 -07:00
teknium1
8a01701de3 refactor(desktop): drop the onScreen shim; tests read matchingWorkArea directly
computeWindowOptions calls matchingWorkArea itself, so onScreen was a one-line
wrapper kept only for two tests. Retarget them at the real helper.
2026-09-18 12:43:36 -07:00
teknium1
e78e6bd4cc style(desktop): restore EOF newlines and blank-line padding in window-state
Follow-up to the salvaged clamp commit: put back the trailing newlines it
stripped from window-state.ts / window-state.test.ts and add the
padding-line-between-statements blank lines eslint asks for in the new
matchingWorkArea hunks. No behaviour change.
2026-09-18 12:43:36 -07:00
KeelTrace
35b94ca208 fix(desktop): clamp restored window bounds to work area 2026-09-18 12:43:36 -07:00
Hass Khalife
4898326ed2 fix(desktop): route message.react through the session's owner, not the ambient gateway
The reactions store dialed activeGateway() directly, so a reaction on a
secondary-profile session (or any session whose owner differs from the
foregrounded profile/connection — Bot-Mode tiles, post-reconnect
rehydration) hit a backend that never held the runtime and answered
4040 "message not found" even though the row existed in the owning
profile's state DB.

Route through requestForOwnedSession (tile route → owner hint →
connection-tagged row ladder, fail-closed), the same dispatcher every
other session-scoped RPC uses. The bound ambient request stays as the
legacy single-profile fallback, preserving the asserted call shape.
Diagnosed independently by emanogilbert-hash on #80670.

Refs #80670.
2026-09-19 00:59:32 +05:30
kshitijk4poor
0571c4e887 perf(desktop): test the pending reply's parts in place instead of joining the transcript per flush
The wake edge recomputes on every streamed flush (~30/s); joining the whole
reply into a string only to check it is non-blank allocated the transcript
each time. A short-circuiting scan of the text parts keeps the contract
(wake once the first non-blank text exists) with no allocation.
2026-09-19 00:58:59 +05:30
Daniel Muzology
e98090e09e fix(desktop): wake voice loop from stable reply edge 2026-09-19 00:58:59 +05:30
Daniel Muzology
93dc760d8a fix(desktop): stream voice replies from live message deltas 2026-09-19 00:58:59 +05:30
teknium1
1e4952ddba fix(desktop): bound the dispatchedTo scan to the current exchange
The backward scan for a `message_agent` dispatch to the inbound sender ran
over the whole thread, so one dispatch to a teammate disabled the deliberate
"Replied to" fold (#85884) for every later unsolicited delivery from that
teammate for the rest of the session.

Stop the scan at the nearest earlier human user row, or at the previous
inbound "Message from" row signed by the same sender. One dispatch now
exempts only the answer that follows it.

Control test: dispatch to @Hermes, relayed answer, two ordinary turns, then
an unsolicited "Message from 🤖 Hermes" — the reply to it must fold again.
2026-09-18 11:12:51 -07:00
teknium1
625070d5b7 fix(desktop): keep a Bot Chat report expanded after a teammate answers this bot's dispatch
In Bot Mode the assistant message that follows an inbound
"Message from 🤖 <bot>" row was always folded into a "Replied to <bot>"
notice. Seen from the bot that DISPATCHED (message_agent → teammate →
answer comes back as that inbound row), the next assistant message is
its report to the human, not a reply to the teammate — nothing is sent
through it — so the fold hid the substance of the turn behind a
disclosure the operator had no reason to open (#114629).

Narrow the collapse predicate instead of removing the compact-exchange parity
fold (#85884): skip the fold when the thread before the inbound row
holds a `message_agent` tool call from this bot whose target names the
inbound sender (handle or display name). Unsolicited deliveries — the
recipient side, whose reply the transport does relay back — keep the
existing collapsed rendering.

Fixes #114629
2026-09-18 11:12:51 -07:00
teknium1
abbbe2a90a fix(desktop): pin that the cold-start restore announces the session the pre-session draft moves onto
The hook test drove announceNewSessionDraftKey by hand, so reverting the
restore's announce sites kept every test green. Stash a __new__ draft,
let the remembered-route restore run, and require the composer's
adoption to succeed for the restored key.
2026-09-18 11:01:11 -07:00
teknium1
a4b48064b1 fix(desktop): move the pre-session draft only when the fresh chat is re-homed, keyed on the scope swap
The salvaged guard migrated the `__new__` bucket when the composer's runtime
session id went from empty to set in the same render as the scope change.
That misses the reporter's path and over-reaches on another:

- Cold-start resume-last-session (use-desktop-integrations) navigates to the
  remembered route as soon as the session list loads; the route flips the
  composer scope immediately while `session.resume` publishes the runtime id
  later, so the guard saw an empty id and never migrated — the typed text
  vanished exactly as reported.
- Opening an existing session from a fresh chat also goes empty → set, so the
  guard would carry the new-chat draft into whatever session the user clicked.
  Drafts are per scope by design (the id-rotation migration in chat/index.tsx
  is deliberately same-conversation only); a sidebar click must leave the
  new-chat draft in its bucket.

The composer swap cannot tell the two apart from its props, so the site that
assigns the id says so: `announceNewSessionDraftKey(stored)` at the two seams
where a fresh chat becomes a stored session (first-send `session.create` in
createBackendSessionForSend, cold-start restore of the remembered session /
route), consumed once by the swap effect via `adoptNewSessionDraft(scope)`,
which moves the bucket after the outgoing cleanup stashed the live editor text
under it and before the incoming scope is restored — so the text follows the
chat with no duplicate left in `__new__`. The existing helper still refuses to
overwrite a non-empty destination.

The `'active'` sentinel in use-composer-draft.ts is a focus-routing target
(`markActiveComposer`), never a draft key, so it needs no migration.

Tests: the hook test now flips the scope with the runtime id still unknown
(red on origin/main and on the salvaged guard alone) plus a control that a
plain new-chat → existing-session switch leaves the bucket untouched (red on
the salvaged guard alone). The store-level duplicate of the migration test is
dropped; the non-empty-destination invariant stays.
2026-09-18 11:01:11 -07:00
KoNit-K
ec0f840db8 fix(desktop): migrate pre-session composer drafts 2026-09-18 11:01:11 -07:00
teknium1
fff7c83113 test(shared): pin the interrupted-replay race to the issue's 3-socket sequence
Extend the salvaged regression test so it asserts the exact invariant from
#114048 rather than only "a replay happens": socket 3 asks for
last_seen=1, a live seq=3 racing that replay is parked by the NEW hold
(watermark stays at 1 until the gap is recovered), and the final order is
[1, 2, 3] with the watermark at 3. On origin/main this fails at the first
assertion (no replay is sent on socket 3 because the stale flag is still
set), which is the reporter's observed `replayOnThird: []`.
2026-09-18 11:00:38 -07:00
KoNit-K
93b3aa0674 fix(desktop): restart replay after interrupted reconnect 2026-09-18 11:00:38 -07:00
teknium1
ed45828c8b fix: inject platform into buildCommandScreenshotMonitor instead of faking process.platform in tests
The builder takes an optional `platform` (default process.platform) so the
argv test can drive the darwin and non-darwin branches explicitly. The test
no longer redefines process.platform via Object.defineProperty.
2026-09-18 11:00:02 -07:00