Commit Graph

4159 Commits

Author SHA1 Message Date
liuhao1024 05f548f35d fix(desktop): declare rememberLog state before the top-level pool-limits read
readPersistedPoolLimits() runs at module evaluation and logs through
rememberLog() on every branch, but hermesLog / desktopLogBuffer /
desktopLogFlushTimer / desktopLogFlushPromise were declared ~110 lines
later. esbuild lowers const/let to var, so the packaged desktop died on
every launch with "Cannot read properties of undefined (reading 'push')"
(#101941, #101960). Moving the four declarations above the read fixes the
crash and keeps the early [pool-limits] line in desktop.log.

Salvaged from #101945 (test dropped: Desktop E2E lane is disabled in CI).
2026-09-03 01:14:37 -07:00
cmyyy 3ea71a47b3 fix(desktop): refresh Bot Chat transcript when a roster click fronts an already-open tab
A roster click on a bot whose canonical Bot Chat is already open only
fronted the tile: the pane kept whatever transcript it last painted,
which can predate rows the bot wrote while the user was elsewhere (a
cron delivery, a teammate's message_agent, another bot's turn). The
stale snapshot persisted until the next user turn — #95600's forceResume
only covered the not-yet-open registry path.

Reuse refreshOpenBotChat (the #99393 reclaim mechanism) on the fronted
branch so forceResume re-pulls the latest transcript. Regression test
pins the behavior: fronting an open Bot Chat now requests the canonical
registry open.
2026-09-03 01:10:16 -07:00
Edder Talmor 37fd6eea97 fix(desktop): toast action is a real button, not a hairline text link
The notification action (`NotificationItem`) rendered as
`variant="textStrong" size="xs"` — an 11px underlined muted-grey text link
with a ~44x20px hit target. On the data-training confirm toast raised by
`surfaceModelSwitchConfirm` / `confirmModelWarning` (e.g. picking
`muse-spark-1.2-contributor`) it read as a footnote, not the one action
the toast exists for, and users reported not being able to "press to
accept".

Promote it to the SDK's `default` variant at `size="sm"`: a filled
primary button, larger hit target, obvious affordance. No new styles.

Salvaged from #96562 (toast half only). Refs #96563.
2026-09-03 00:58:46 -07:00
Teknium b6041240d1 test(desktop): trim Bot Chat pane-focus regression to the two invariant cases
Salvage follow-up to #101639 (@helix4u): keep the re-adopt-after-overlay and
miss-propagation cases, drop the hidden-pane and healthy-path controls.
2026-09-03 00:36:09 -07:00
Gille b6d549d002 fix(desktop): restore missing Bot Chat panes before claiming focus 2026-09-03 00:36:09 -07:00
hermes-seaeye[bot] e629c900a8 fmt(js): npm run fix on merge (#101952)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-03 07:24:01 +00:00
Teknium bd6cc48b94 fix(desktop): annotate the resolvable target for aliased LOCAL rows too
The contributor fix covers remote rows. The reporter's video shows the
sibling shape: with a remote gateway active, the LOCAL twin carries the
'default-this-device' alias, and message_agent's local resolver only
knows bare profile names / 'hermes'. Emit the same target annotation
whenever a local row's alias differs from its resolvable handle.
2026-09-03 00:18:31 -07:00
liuhao1024 2e542c92e6 fix(desktop): annotate canonical relay targets for remote @mentions
The Bot Mode mention middleware built message_agent targets from
botHandle(), which prefers a roster row's source-qualified UI alias
("default-vera"). Neither resolver accepts that form — the relay matches
canonical handle/profile (± @connection-id) and the local path a bare
profile name or "hermes" — so remote handoffs died with "No teammate
named" before enqueue.

Annotate the canonical form instead: profile@connection-id for remote
rows, canonical bare handle (default→hermes) for local ones. Pin the
profile@connection form on the relay side too, so the emitted target
stays inside the documented resolver contract (#97678).
2026-09-03 00:18:31 -07:00
Finn763 e245e40f73 fix(desktop): warm session switch pegs renderer main thread (#95595)
Switching to an already-open session remounted the incoming transcript and
re-tokenized every fenced code block from scratch on the main thread (N blocks
x full shiki tokenization per switch, 96-100% CPU for seconds).

- shiki-block: content-keyed LRU cache of highlighted HTML (theme scope +
  language + code); remounts of unchanged blocks paint cached markup with
  zero highlighter calls; misses debounced, failures degrade to plain text
- use-session-actions: warm resume keeps the session-slice array when the
  reconciled content is equivalent (same guard as the cold path), so the
  runtime repository and every row keep identity
- transcript-window: per-session sticky window memos; a warm re-visit with an
  unchanged transcript reuses the windowed slice by reference (no re-index,
  no repository rebuild); sticky cut survives switches for sessions that grew
- perf regression guards: remount must not re-tokenize (codeToHtml called
  once per unique block), windowed slice reference preserved across switches,
  messageComponents identity stable across session switches
2026-09-03 12:19:11 +05:30
kshitijk4poor f260ea5347 fix(desktop): let the spawn coordinator follow the live pool max
Composition of #92581 on #100985: the hard cap is a constructor constant,
so raising the pool max in Settings would have left new spawns queued
behind the launch-time value. Add setLimit(); slot hand-off now goes
through a single #drain that respects the current cap, which also fixes
the original release path handing a slot to the next waiter even when the
cap had just been lowered (test: lowering never revokes granted slots; new
requests queue until under cap). main.ts constructs from
poolLimits.maxBackends and pushes changes from setPoolLimits(); pinned by
a wiring test.
2026-09-03 12:19:05 +05:30
ClintonEmok c401756a6a fix(desktop): pool sizing as a live device preference in Settings (#91545)
Hover-intent prewarm sweeps across the Bots rail spawned past the pool cap,
LRU-evicting the backend the user was about to click — an evict/respawn
cascade that made profile switching progressively slower (#91545).

- prewarmProfileBackend skips speculative spawns once every pool slot holds
  an open socket; the real click still spawns on demand.
- Pool max/idle become a device preference (Settings -> Advanced), persisted
  atomically in userData (pool-limits.json) and applied live over IPC; the
  HERMES_DESKTOP_POOL_* env vars remain the initial fallback. Defaults are
  unchanged (3 backends / 10 min idle).

Squash of the 3-commit PR #92581 branch (a00dc088c5..783899d12f) applied
via diff onto the spawn-coordinator salvage; import + constant-block
conflicts resolved so the coordinator is constructed from, and follows,
the live preference (setLimit added in the next commit).
2026-09-03 12:19:05 +05:30
kshitijk4poor 5810172f52 fix(desktop): bound the pool-slot wait below the renderer boot budget
Follow-up to the spawn coordinator: the queued ticket waited up to
POOL_IDLE_MS (10 min) for a free local slot, but the renderer gives up on
a backend boot after 45 s. A user clicking a 4th profile with 3 fresh
backends open would see the generic "backend didn't come up" error while
the ticket kept the pool key hostage, so every later click joined the same
stale wait. Cap the wait at 30 s, log the slot pressure when it happens, and
pin the relationship to BACKEND_BOOT_WAIT_TIMEOUT_MS with a wiring test
(fails when the timeout is reverted). Also eslint --fix on the salvaged
files (import order was a lint error).
2026-09-03 12:19:05 +05:30
Kryptonator e924615bb1 fix(desktop): cap concurrent local profile backend spawns
Desktop could spawn a local hermes serve per profile with no hard cap on
starting+running children: LRU eviction spares keepalive-fresh entries, so
a roster refresh across many profiles became a process wave (40+ backends,
load 30-50 reported).

LocalBackendSpawnCoordinator: at most POOL_MAX_BACKENDS local backends may
be starting or running. Remote descriptors never take a slot. Queue tickets
are per request; a slot is released only after process exit is proven
(exitCode/signalCode). A rejected wait keeps the slot occupied. Pool entries
re-assert ownership at each await so an evicted entry cannot spawn a zombie.

Squash of PR #100985 (6017abbbc4 + merge), applied via diff onto current
main. Original commits were authored as 'Motor (Hermes AI) <ceo@xtremagency.com>';
attributed here to the PR author's GitHub identity.
2026-09-03 12:19:05 +05:30
cez0060405 bb319d0d78 fix(desktop): bound orphan-reap sweep so slow probes cannot stall boot
The ownership file accumulates one record per profile per launch, and each
record can cost up to two identity probes (parent + backend) plus a stop.
On Windows those shell out to PowerShell, whose 5.1 cold starts are slow.
Without a bound, a large roster could stall boot for minutes while the
renderer's 45s backend-boot budget expires and the user stares at the
connecting screen.

- Add REAP_PROBE_TIMEOUT_MS (5s) for the orphan-reap path; the claim path
  keeps the full 30s headroom for a freshly spawned backend's marker.
- Add reapDeadlineMs (5s default) as an overall budget for one reap sweep;
  when exhausted, unprocessed records are preserved for the next launch.
- stopOwnedBackend now throws when the identity probe fails (not confirmed
  gone) so the record is preserved instead of leaking the backend.

Verified: two cold starts complete in ~29s (was 5+ min); renderer connects
immediately after backend ready.
2026-09-03 12:17:54 +05:30
hermes-seaeye[bot] 97f3229dfd fmt(js): npm run fix on merge (#101836)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-03 03:17:48 +00:00
ethernet 75aaf56904 enable local models by default on win32 and darwin 2026-09-02 22:40:22 -04:00
hermes-seaeye[bot] 4e66da6eeb fmt(js): npm run fix on merge (#101497)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-02 17:45:59 +00:00
Brooklyn Nicholson 6ef6691960 fix(desktop): a saved comment crop shows its own marker, and only its own
Two faults in the crop that "Add N comments" attaches, both visible in a
two-comment batch on one page.

The marker was missing. showDraft sets the marker's style and resolves, but
resolving only means the property is set — the compositor has not drawn it.
capturePage then photographed the frame before the marker existed, so crops
arrived outlined in blue with no number, while the prompt line said "Image N
marks the target in blue". beginCapture now waits two animation frames, which
puts the shot after the paint.

The wrong marker could appear. Saved pins stay drawn on the page, so any pin
within the crop padding of the new element landed inside the shot: a comment
on a heading came back carrying the marker belonging to the comment on the
paragraph below it, pointing the agent at the wrong element. Saved pins and
hover chrome are hidden for the duration of the shot and restored after.

Restoring runs in a finally, so a capture that throws cannot leave every saved
pin invisible on the page, and the guest calls are best-effort — a torn-down
overlay degrades to the old unbracketed shot rather than failing the capture.
2026-09-02 12:40:00 -05:00
Brooklyn Nicholson 06a4f4ab31 feat(desktop): group a comment batch by page region so it lands as a few tasks
Twenty-three comments arrived as twenty-three flat blocks, so the agent made
twenty-three todos and ground through them one at a time. They now arrive
grouped by where they sit in the page, with a line telling the agent to work
the groups rather than the comments.

The renderer groups on structure, not meaning. Whether a comment is a UI nit
or a functional bug is a judgment only the model can make, and prose-matching
it here would be wrong constantly; which pins share a DOM subtree is something
the selector already answers. That split is also the one that makes parallel
work safe — grouping by theme instead ("all the spacing ones") cuts across the
same components and puts several workers in the same files, so the guidance
says to hand out whole groups and never to regroup by theme.

Grouping compares ancestor paths, so a heading and a paragraph in one card
stay together instead of becoming two singletons. Depth is derived rather than
tuned: descend the shared prefix until it stops being shared, then sub-split
any group still holding more than a third of the batch — without that pass a
normal page buries every section under `main`. Batches under four comments,
and batches that all land in one region, stay flat.

Grouping is advice in the prompt, never an action: the renderer does not spawn
or delegate anything. That stays the agent's call.
2026-09-02 12:29:25 -05:00
Brooklyn Nicholson e4bda3ff77 feat(desktop): browser comments carry the element's selector, markup, and styles
Comment mode shipped the crop and the note, so an agent got a picture of the
problem and had to grep for the element it showed. Each element comment now
also names its CSS selector, its markup, and the computed styles that decide
layout, which is what the agent needs to land in the right file.

The target line stays prose — it is what the user pointed at — and the DOM
detail rides labelled lines beneath it. Area pins have no element, so they
still get only the crop and the note.

Markup is redacted in the guest before it crosses to the host: password and
hidden input values, and any attribute reading as a key/token/secret, are
replaced with [redacted] on a clone, so a page's secrets never reach the
composer or the model. It is clipped to a 600-char budget so one comment
cannot paste a whole section.

AnnotateIdentity was a hand-copy of CompactIdentity that had already drifted;
it is now an alias, so the guest, the pin, and the packer cannot disagree
about the shape again.
2026-09-02 12:29:25 -05:00
hermes-seaeye[bot] bcef556b00 fmt(js): npm run fix on merge (#101481)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-02 17:18:47 +00:00
Teknium 7840a0e2d9 feat: delegation batch tags read "set N" instead of a hex id slice
Interleaved subagent fan-outs were tagged with the first 4 hex chars of the
delegation id ([b2ac 3/9]), which is attributable but unreadable. Batches are
now numbered in order of appearance per process: [set 1 · 3/9], [set 2 · 1/7].
Desktop /agents already labels groups "Delegation N", so its duplicate hex
badge is dropped.
2026-09-02 10:12:54 -07:00
Brooklyn Nicholson 195c3e5a3b fix(desktop): refuse every resume for a chat the user is deleting
The leftover-4001 fix stopped the dispatcher from CREATING a rebind
after a tombstone, but a request queued before it still fired, and the
push path (markRuntimeGone) never checked at all. Both funnel through
requestSessionResume, so guard there: a removal-pending id never gets
queued, and no consumer has to re-derive whether an id is doomed.

isSessionRemovalPending is now the single predicate. resumeSession keeps
its entry guard for requests queued before the tombstone; the session
tile gets the same guard via shouldResumeSessionTile, closing the tile
twin where a 4001 racing a delete unbound the runtime and re-armed the
resume effect against a dead id.

Drops the !freshDraftReady clause on explicitlyRequested: a gateway
switch also stages a fresh draft while leaving the URL on /:sid, where
an explicit request is the only remaining resume lever, so that clause
silently dropped plugin and SDK reselects after a connection apply.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
2026-09-02 11:23:50 -05:00
Brooklyn Nicholson 86acfee949 refactor(desktop): give session tombstones their own store
The delete/archive tombstone atoms lived in store/projects.ts, which
imports store/session. Resume lives on the other side of that edge, so
consulting the tombstones from the resume path would have closed an
import cycle. Move the atoms and their mutators to store/session-removal
and repoint every consumer; no behavior change.
2026-09-02 11:23:50 -05:00
xxxigm 66109d3bbd test(desktop): leftover 4001 rebind must not revive a deleted session
Cover the delete-transition race and the tombstone early-return so the Resume failed toast cannot come back through those paths.
2026-09-02 11:23:50 -05:00
xxxigm 990879d688 fix(desktop): don't rebind a deleted chat from a leftover 4001 resume
A queued requestSessionResume still fired during the /:sid -> /new tick after delete, re-selected the doomed id, and toasted Resume failed / Session not found.
2026-09-02 11:23:50 -05:00
Teknium d9b051a9d6 fix(desktop): pin the project '+' new chat to the profile its tree is shown under
workspace-session-target.ts never set $newChatProfile, so a new session
started from a project's "+" reached desktopSessionCreateParams with no
intent and fell back to $activeGatewayProfile — which an in-flight profile
swap can move between the click and Send, landing session.create on the
wrong backend (#79005 flaw 3, second path). Pin the tree's profile
(projectProfile()) via the same intent write newSessionInProfile uses.

Fixes #79005
2026-09-02 06:47:55 -07:00
Ayush Nangia 9189e42842 fix(desktop): reconnect an active profile whose gateway is closed 2026-09-02 06:47:55 -07:00
Teknium dfa74b5815 fix(desktop): boot session pop-out/watch windows against the session's owning profile
`openSessionInNewWindow` → IPC `hermes:window:openSession` →
`buildSessionWindowUrl` emitted no `profile`, so a secondary window (⇧⌘-click
pop-out, subagent watch) was a full renderer that adopted the PRIMARY
backend's profile and resolved the session id against the wrong store —
blank/wrong session for any non-primary profile (#82768, #61286).

The owning profile now rides the URL as `&profile=`, exactly the carry the
HUD already does (buildHudWindowUrl / windowProfileOverride in
use-gateway-boot); the renderer picks it with the same ladder openHud uses:
the session's stamped owner wins, an unstamped/uncached id (a brand-new
subagent child) inherits the profile the user is looking at.

Diagnosis credit: @DomGrieco (#82794).

Co-authored-by: DomGrieco <6556434+DomGrieco@users.noreply.github.com>
2026-09-02 06:47:55 -07:00
SZWzz ff8c1aca7a test(desktop): cover combined remote route precedence 2026-09-02 06:47:55 -07:00
SZWzz ae81786580 fix(desktop): let a per-profile remote override win over the forced-local route (#90477)
resolveRegistryLocalRoute collapsed globalRemote and profileRemoteOverride
into one forced-local branch. The two cases are different:

- globalRemote: forcing "This device" to spawn genuinely-local children is
  the intended migration behavior — unchanged.
- profileRemoteOverride: the per-profile SSH/remote override is an explicit,
  authoritative routing decision for that profile. Forcing local made the
  roster enumerate the profile via its override but open the thread in a
  forced-local child, which dies with 'Profile "x" no longer exists' when
  the profile only exists on the remote — reproduced on a macOS Desktop in
  global SSH mode where mythony-agent/q-agent exist only on the NAS.

The registry 'local' entry now delegates to the legacy profile route when a
per-profile override is present, so the override stays authoritative.

Tests: the override case now pins delegation, and a new witness pins that
globalRemote alone still forces local; both contracts are asserted together.
88 connection-registry + 91 remote-lifecycle + 73 routing tests pass;
tsc --build clean.
2026-09-02 06:47:55 -07:00
Teknium e9dd0bf5d5 feat(desktop): polish bot roster sections — dialog rename, Undo delete, Esc-cancel drag, nested under gateways (salvage #100745)
Follow-up on @fortun8te's user-made roster sections:

- Sections start empty: no seeded General/Workforce/Clients. With no
  sections created the roster renders exactly as before.
- New section and Rename go through one Dialog + Input + Cancel/Save
  (the app's session-rename shape) instead of an inline caret; the row
  menu's "New section…" files the bot as it creates.
- Delete needs no confirmation: bots return to Unassigned and the toast
  offers Undo (restores the section in its slot and refiles its bots).
- Drag: single-row drag under a private MIME type, every valid target
  shows a faint outline while a drag is live, the hovered target lights
  up, the source section refuses the drop, Escape cancels, and the moved
  row no longer stays faded after it remounts under its new section.
- Multi-select (cmd/shift-click, querySelectorAll shift-range) dropped:
  the roster has no selection model. Per-bot saveBotMeta writes run in
  sequence, one per profile (membership IS a field on each profile).
- Section heading reuses RosterSectionHeader (gains `action` /
  `onDoubleClick`), so user sections fold and look like the gateway
  headings; ⋯ menu and right-click drive the same Rename / Move up /
  Move down / Delete. Empty sections show a dashed "Drag bots here" slot.
- Composes with gateway buckets: sections nest INSIDE each connection
  bucket, indented under a hairline rail (membership lives in the bot's
  profile on that gateway); empty sections repeat there only mid-drag.
- Full i18n parity (en / ja / zh / zh-hant) for every new string; icon
  toggle and the storage-async plumbing removed.
- Tests trimmed to the three invariants (membership persists through
  saveBotMeta + reload, remainder = Unassigned, delete returns bots +
  undo) plus a live Electron e2e covering the whole flow.
- Docs: "Organize bots into sections" in user-guide/bot-mode.md.
2026-09-02 06:06:32 -07:00
Michael Knaap bd9955d529 fix(desktop): section rename — Escape cancels, Enter commits once
Closing the rename field unmounts the input, and the unmount can still
fire onBlur, which committed the draft the user had just asked to throw
away with Escape. Enter also called commit() directly and then again from
blur. Route both through blur with a cancelled flag so the commit runs
exactly once and Escape never renames.
2026-09-02 06:06:32 -07:00
Michael Knaap 3d0ac691af feat(desktop): user-made sections in the bot roster, with drag-and-drop filing
The roster already has sections, but only automatic ones: one per gateway
connection plus the group-chat bucket. Those answer "where does this bot
run", which is not the question being asked when someone wants two client
bots filed together under "Clients" and the internal ones under "Team".

This adds a second axis that composes with the first: gateway sections keep
the top level whenever more than one connection is showing, and user
sections group the flat list underneath.

Design choices, each deliberate:

- Membership lives on the BOT (`ui_meta.sectionId`), not as a member list on
  the section. A bot can only be in one place, deleting a section cannot
  orphan anybody, and the assignment rides the same profile.yaml sync every
  other bot setting already uses, so it follows the profile to another
  machine. Section records (id, name, icon) live in plugin storage.
- "Unassigned" is not a section. It is whatever is left, always drawn last,
  and it is where members of a deleted section land. No record, so nothing
  to keep in sync.
- Three gestures, one rule: drag a row onto a section heading; cmd/ctrl-click
  and shift-click build a multi-selection (shift ranges in DOCUMENT order,
  anchored Finder-style); and the row's context menu gets "Move to section…"
  with the same targets the drag would use. Dragging a row that is part of
  the selection drags the whole selection.
- The drag uses a private MIME type, so a bot dropped on the composer or the
  transcript is simply not a valid payload there instead of pasting its key
  as text.
- Section headings rename inline (double-click / menu), reorder, hide their
  glyph, and delete (keeping their bots). Right-click and the ⋯ button open
  the same menu so neither can drift.

With no sections created the roster renders exactly as before.

Tests: user-sections.test.ts covers the pure model (normalisation, grouping
with unknown/deleted sections falling to Unassigned, drag payload
round-trip). The existing hermes-bots suite passes; tsc and eslint clean.
2026-09-02 06:06:32 -07:00
Teknium 648c664eb3 feat(desktop): gate cold-start restore on display.resume_last_session
- use-desktop-integrations: hold the restore latch until the config
  record answers; when false, stay on the fresh chat (route and session
  restore alike) while still remembering the open chat for next launch.
- wiring: read the shared config-record query; undefined while pending,
  fetch failure falls back to the historical behavior (resume).
- appearance-settings: ToggleRow writing through the shared config
  cache with rollback + notifyError on a failed save.
- ar/ru strings, docs line in user-guide/desktop.md, two hook tests.
2026-09-02 05:56:54 -07:00
jinglun010 7aff724e56 feat(desktop): add display.resume_last_session config toggle (#60812)
Config default (true) plus the Appearance-settings strings for a
"Reopen Last Chat on Launch" switch. Salvaged from PR #60816 onto
current main (defaults moved to config_defaults.py since the PR).
2026-09-02 05:56:54 -07:00
liguoyu 1592e48ac9 fix(desktop): a split-dragged Bot tab keeps its Bot workspace scope
Dragging a session tab to split in the Bot workspace committed through
openSessionTile with no scope, whose default { workspaceMode: 'sessions' }
was written onto the already-open tile before the pane move — so the tile
re-bucketed into the Sessions workspace and vanished from the Bot strip.

A scope-less open of an already-open tile is a MOVE, not a re-scope:
default the scope to the tile's current workspaceMode and only rewrite
scope when the caller passed one explicitly (sidebar/bot openers still
win). Brand-new tiles keep the 'sessions' default.

Closes #96865
Supersedes #96998

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-09-02 05:54:10 -07:00
Teknium d1f8c2ea11 test(desktop): group chat picker row label opts out of min-width:auto so names truncate
Renders CreateGroupChatDialog with a long-named bot and asserts the row
label carries min-w-0 (and the inner text column keeps min-w-0 flex-1 +
truncate). Sabotage-verified: fails against the pre-#90624 markup with
'expected [...] to include min-w-0'.
2026-09-02 05:42:14 -07:00
Jay c65c79a4a8 fix(bot-mode): group chat picker rows overflow and scroll names out of view
The New Group Chat picker's rows are `label` flex containers holding a
`min-w-0 flex-1` text column whose two lines are `truncate`. The label
itself has no `min-w-0`, so as a flex item it keeps its `auto` minimum
width and cannot shrink below its content. `truncate` therefore never
fires: the row grows to fit the longest secondary line instead, which is
`@handle · in "Group A", "Group B", …` and so scales with how many groups
a bot already belongs to.

The row is a grid item inside a Radix ScrollArea, whose viewport wraps
children in a `display: table` div that sizes to content, so the whole
list widens rather than clipping.

Measured on a 414px viewport with one bot in three groups:

  wrapper width  593  (viewport 414)
  widest row     585
  overflows      yes

Nothing is visibly wrong until the first click. The checkbox now sits
past the right edge, so focusing it scrolls it into view: `scrollLeft`
jumps 0 → 178.38 (= 593 − 414) and every row shifts to `left: -162px`,
clipping the bot names from the left — the user clicks a name and the
names disappear. The scroll offset persists after unchecking, until the
dialog is remounted.

Adding `min-w-0` to the label lets it shrink, so `truncate` engages as
the markup already intended. Same viewport, same data:

  wrapper width  414  (unchanged display: table)
  widest row     406
  overflows      no
  scrollLeft after clicking a row  0 → 0

`display: table` on the ScrollArea wrapper is untouched; the fix works
with it rather than around it. Verified against a packaged build via CDP,
before and after, on identical roster data.

No test. The rule for this is "extract the logic into a small
pure/DI-testable function and call it for real", but there is no logic
here — `min-w-0` is a class name, and the behaviour under test belongs to
the layout engine. jsdom does not lay out, so a unit test cannot observe
the overflow; the Playwright suite could, but has no bots-roster fixture,
which is a large scaffold to hang off a one-class change. A source-regex
assertion would pass without ever laying anything out, which is precisely
the false confidence AGENTS.md describes. The before/after measurements
above are offered as the evidence instead — happy to add a Playwright
case if you'd rather have the fixture.
2026-09-02 05:42:14 -07:00
Teknium 8d6a286fe8 fix(desktop): restored background tabs resolve their session title without a click
A restored session tile has no runtimeId and never mounts its pane until first
activation, so the by-id resolution effect inside SessionTilePane never runs.
When the row is also outside the recents page and project tree, tileTitle()
falls back to "New session" until the user clicks the tab (#94167).

Add a one-shot backfill, wired next to watchSessionTiles(): once the gateway
is open, look each unrestored, untitled, unlisted tile up via
resolveStoredSession(id, tile.ownerRoute). That call already upserts the row
into $sessions, which the tab strip watches, so the tab renames itself —
nothing new is persisted and workspaceTabTitle stays the Bot Chat marker.

Live repro (Electron e2e, target session pushed off the 50-row recents page
by 60 newer sessions, restored as a stacked background tab): main showed
"New session" after boot with no click; with this fix the tab reads the real
title while the pane is still unmounted.

Closes #94167
Supersedes #94212

Co-authored-by: 686f6c61 <github@00b.tech>
2026-09-02 05:42:00 -07:00
Teknium ad800ea8cd fix(desktop): cold resume paints the prefetched REST transcript before session.resume settles
The REST prefetch and the gateway `session.resume` already ran concurrently,
but the prefetch result was held until the runtime resume settled. A cold
profile build (skills / MCP / memory) can keep `session.resume` pending past
the hydration budget while the complete transcript is already in hand, so a
Bot Chat sat on the loader and burned its retries with readable history
off screen.

- Publish the grafted REST snapshot as soon as the prefetch resolves and
  `isCurrentResume()` holds; the runtime path grafts only its live projection
  onto that same snapshot, and the post-resume `chatMessageArraysEquivalent`
  skip keeps reference identity when nothing changed (no second DOM build).
- Stamp the eagerly painted page with persisted-display provenance on the
  runtime state so the warm-path gate admits it on the next switch.
- REST fallback after a resume rejection skips the redundant re-publish when
  the early paint already shows the transcript.

Live repro (Electron e2e, session.resume stalled 25s via a temporary
backend shim): main never painted within 20s; with this fix the transcript
painted 150ms after the row click.

Supersedes #90130.

Co-authored-by: Alexandre Roumieu <269586168+alexandreroumieu-codeapprentice@users.noreply.github.com>
2026-09-02 05:41:47 -07:00
Teknium 1bf2cf57ef fix(desktop): project tree follows sessions.changed so external session create/delete/rename/cwd changes show up (#100354) 2026-09-02 05:40:18 -07:00
Teknium 209de12d5f fix(desktop): Bot Mode tabs caption a Bot Chat with the bot's name, not "Bot Chat"
Every bot's canonical chat is stored under the same title ("Bot Chat" — the
name the gateway resolves it by, and an invariant roster-actions.ts's stale-tile
probe and #90102 rely on), so the main tab strip captioned every open bot chat
identically and two bots' tabs were indistinguishable (#99152).

Fix at the presentation layer, leaving the stored title and tabTitle untouched:
- workspace-scope.ts gains `$workspaceOwnerLabels` + `workspaceOwnerTitle()`:
  a bots-mode tab whose resolved title still equals its registered placeholder
  reads its owner's label instead. Side threads / Sessions tabs are untouched.
- session-tile.tsx captions tiles through it (and the drag payload); the main
  `workspace` tab (controller.tsx) does the same via `$botChatScopes`, the
  bot-mode scope the main tab was last opened under (it has no tile).
- The hermes-bots roster publishes displayName() per owner key through the new
  `host.setWorkspaceOwnerLabel` (feature-detected), so renames follow.

Supersedes #99177, which set tabTitle at open time — that reverts after mount
because tileTitle() prefers the stored row's title once the hidden row is
upserted, and breaks the `workspaceTabTitle === 'Bot Chat'` invariant.

Tests: one unit test on workspaceOwnerTitle() (bot chat → bot name; side
thread / sessions tab / unlabeled owner untouched) and one Electron e2e
(tab strip reads "Alpha", not "Bot Chat"); both fail on main, pass here.

Closes #99152
Supersedes #99177

Co-authored-by: twotnguyen <nguyenngoctinh011258@gmail.com>
2026-09-02 05:38:10 -07:00
Teknium 7b951e46ab fix(desktop): route a shared-id unpin to the row the pull adopted
With every slice feeding the pin sync, two profiles can legitimately hold
the same session id. The pull already tie-breaks toward the active gateway's
row (rowsByPinId), but the push resolved the profile by first match — so an
unpin PATCHed the other profile and the next page re-adopted the pin.
Resolve the write's row with the same active-gateway preference.

Case and test from #92609.

Co-authored-by: Jake Vincent <45184202+jakewvincent@users.noreply.github.com>
2026-09-02 05:37:58 -07:00
fangliquanflq d417aee387 fix(desktop): sync pins across all session slices 2026-09-02 05:37:58 -07:00
Teknium 9b84a98e29 test: trim salvaged #99763 to the two invariant-pinning tests
Drop the no-field fallback vitest case and the get_compression_chain
unit test: the fallback is implied by the predicate (Boolean(undefined))
and the chain walk is covered by test_list_serves_full_lineage_ids_for_projected_rows
through the real list projection.
2026-09-02 05:37:05 -07:00
Alonso 202997b51d fix(desktop): one conversation never opens as two tabs after compaction
Compression rotates a conversation's tip id while tiles stay keyed by
whichever segment id they were opened with. focusOpenSession and
openSessionTile tested exact ids, so right after a rotation the same
chat read as 'not open' and opened again in a second tab — and a tile
keyed to a MIDDLE segment (the tip when it was opened) could no longer
prove it names the conversation at all, rendering as an untitled ghost.

The projected list row now carries the full chain
(SessionDB.get_compression_chain, served as _lineage_ids by
list_sessions_rich and the sidebar tree row), lineageAliases indexes
every segment, sessionMatchesStoredId accepts membership, and the tab
focus/open paths dedupe through the lineage instead of the exact id.
Older gateways omit the field and degrade to today's root/tip pairing.
2026-09-02 05:37:05 -07:00
Gille 2599793271 fix(bot-mode): hand off group tabs to remote bots 2026-09-02 05:36:54 -07:00
chelsealong 87d5e40f53 fix(desktop): fail closed when Bot Chat lookup returns zero rows
session.list can succeed with an empty sessions array during a profile
backend restart instead of throwing, and findExistingCanonicalChat's
`rows.find(...) || null` mapped that to the same value as "this bot
never had a chat". The click path then minted a replacement Bot Chat
and re-fired the kickoff intro on an intact, hidden canonical row,
orphaning in-progress work each time (#98383).

When the roster's own canonical_session already confirms this profile
has a Bot Chat, treat a zero-row result as unconfirmed absence and
fail closed the same way a thrown RPC error already does, instead of
minting.
2026-09-02 05:36:54 -07:00
Teknium 6d061ede58 fix(desktop): open transcript catches up on every reconnect
Closes #94779. A turn that finished while the gateway socket was down never
replays its sessions.changed tick, so the open transcript stayed stale
until the user reopened the session. The gateway-open effect now also
requests one signature-gated tail of the active transcript on every
(re)connect (connection-scoped, so a plain session switch adds no read;
messaging transcripts already refresh on open via their own effect).

Reported-by: Kkkkkuro
2026-09-02 05:36:41 -07:00