Build on #114129 (@KoNit-K), which carries a Desktop-generated large-paste
preview from the composer through `prompt.submit` -> `display_metadata` ->
turn context -> the shared title input. Two gaps closed:
- `apply_instant_title` never received the preview, so the instant title of a
paste-only opener was the generated `@file:` path — and stayed that way,
because the upgrade thread's `derive_title` fallback writes `derived`
provenance, which never replaces the `derived` title already stored.
Thread the hint into the instant stage too.
- `build_title_input` let the `@file:` ref lead when the opener was nothing
but the generated attachment ref; the preview now leads for a ref-only
opener (an instruction still leads when the user typed one).
- `prompt.submit` gains `title_preview` in the contract (regenerated shared
TS/OpenRPC); documented as title-only input in the configuration guide.
- Tests trimmed to two invariants (shared input reaches both stages; budget +
manual attachments stay unread).
Bots filed before sectionName existed carry only sectionId in their profile
ui_meta. The creating desktop knows the section record but never wrote the
name back, so a second desktop on the same gateway had nothing to rebuild
the section from and still drew a flat list.
backfillBotSectionNames runs beside adoptBotSectionsFromMeta in the roster
pane effect: members whose sectionId matches a locally known section but
have no sectionName are re-stamped through moveBotsToSection (one write per
profile, sequential). Once written the name is present, so it is a one-time
pass per member. Sections nobody here knows are left alone.
Docs no longer tell users to re-file or rename to stamp the name.
Section RECORDS live in the creating desktop's plugin storage
(user-sections.ts, BOT_SECTIONS_KEY) while membership (`sectionId`) rides
each bot's profile ui_meta over the gateway. A second desktop on the same
backend therefore held sectionIds whose names it had never seen, and
renderUserSections fell back to the flat list — the "no section headings"
half of #114355.
- every filing writes `sectionName` beside `sectionId` (moveBotsToSection),
so the name travels with the membership
- adoptBotSectionsFromMeta rebuilds records this desktop never created from
its members' meta, and takes a rename once every member agrees on the new
name; the roster pane runs it on each roster/meta change
- renameBotSection re-stamps the members so the new name reaches other
desktops; order and empty sections remain per desktop
- two invariants in user-sections.test.ts (red on origin/main); docs updated
The other half of the report — no "New section" entry in the + menu — is a
stale Linux build: roster-pane-toolbar.tsx and bot-row.tsx render the
entries unconditionally and nothing under plugins/hermes-bots/ reads
process.platform / navigator.platform.
Replace the Playwright spec from #104065 with one jsdom invariant in the
existing GroupMentionInput suite: the popover carries `bg-(--ui-bg-elevated)`
and never a `--ui-bg-{primary,secondary,tertiary}` fill token. The Desktop
E2E job is `if: false` in ci.yaml, so the 96-line spec (two bots, a group,
a 32-paragraph post, a theme toggle) would never run; the token → opaque
surface chain was verified once in headless Chromium (alpha 62 → 255).
WINDOW_OWNED_REQUESTS (preview.act, preview.read, terminal.read,
window.read) treated only the active session as owned by this window, so
a session shown in one of this window's session tiles got its pane reads
'ignore'd by every attached window and the tool stalled until the backend
deadline. Ownership is now "this window hosts the session": the primary
view OR any open tile's runtimeId (same rungs foregroundSessionScopes
walks), read synchronously from $sessionTiles.
Also make the table the single owner rule: 'tour' joins the set and the
duplicated inner `sessionId && !isActiveSession` guards in previewAct and
tour go away (they were unreachable behind the table route; after the
tile widening they would have re-introduced the silent stall for a hosted
but non-active tile session, which now gets the fail-fast refusal
instead).
Widen the owner rule to the sibling bridges answered from THIS window's
panes: terminal.read and window.read alongside preview.act / preview.read.
Every attached window observes the same server request; a window showing
another session has no pane for it and its empty answer would win the
race, so the tool reported "No preview tab is open / No in-app terminal is
open" while the owner's pane was open. The route table lives in one set so
the four bridges cannot drift apart again. Tests trimmed to two invariants.
The honest "unavailable (remote backend)" state (previous commit) tells the
user the copy will never happen; the tooltip now also says what does work
against a remote backend — Install from Git with the Desktop target checked
clones the desktop half onto this machine (the install modal already takes
that branch for connection.mode === 'remote'). Docs note the new state next to
the existing remote-backend paragraph.
Electron's desktop-half reconcile (reconcileUnifiedDesktopHalves) only
walks this machine's hermes homes, so a unified package installed on a
remote backend can never materialize into the app's desktop-plugins
root. The renderer still derived desktopMissing from the backend's
has_desktop_half, painting an eternal 'copying…' state that reads as a
transfer in progress (#114079).
Show an honest 'unavailable (remote backend)' state (with an explanatory
tip) whenever the live resolved connection mode is remote; keep the
pending state for local backends, where the reconcile really can catch
up on rescan or restart.
Fixes#114079
(cherry picked from commit 2e35b47495c699b619f9899da988dea24f2b6632)
/kanban is a workspace page route: the titlebar slot is hidden there and the
switcher is projected into the Kanban page's header row (WORKSPACE_PAGE_HEADER_AREA
after #114960). Point readers, the i18n comment and the test comment at that row.
Follow-up to the salvaged #114647 hunk (visible "Board" label +
`Board: <name>` accessible name):
- replace the native `title` with the app's `Tip` ("Switch board"), the
same tooltip every other dropdown trigger uses, so hover names the
ACTION instead of repeating the board name
- lead the trigger with the plugin's `project` icon so the title-bar
text reads as the Kanban board control, not a static window title
- add `switchBoard` to the kanban plugin bundle (en/ja/zh/zh-hant)
- test registers the real locale bundle and asserts the rendered label,
accessible name and tooltip (the raw-key fallback matched
`/board:/i` by coincidence)
- docs: describe the Desktop switcher and its local selection
Fixes#114642
The contributor test covers the queue fall-through; the Desktop's normal case is
a redirect-capable AIAgent, where busy_input_mode=interrupt turned the edit into
a mid-turn redirect and left the un-edited transcript in place. Pin that branch
too, and document in the rewind section that a truncating submit refuses with
4009 while a turn runs so hosts interrupt + retry (the Desktop already does).
prompt.submit's busy path (_handle_busy_submit) steers/redirects or
queues a mid-turn submit as a plain follow-up, which is correct for
an ordinary message but silently drops the truncation intent of an
edit/restore/regenerate: the original (un-edited) turn keeps running
and its reply lands untouched, reading to the user as "my edit was
rejected" (#113942 — editing a message while Hermes is still
thinking).
The desktop client already has a robust interrupt-then-retry loop for
exactly this race (session.interrupt() is async, so prompt.submit can
still observe running=True right after it) — it just needs the
gateway to report "session busy" (4009) instead of accepting the
submit. Route truncating submits around the queue/steer path so they
hit that existing retry loop instead of being silently absorbed.
(cherry picked from commit 982767f723607870804c73a3e0322a50ccbae08b)
The `.ref` fallback declared `--ref-color: var(--dt-primary)` AFTER every
`[data-ref='<kind>']` rule at the same specificity, so source order made it
win for any element carrying both — which is every inline reference: Bot Mode
group @mentions (agent/human/broadcast), `/skill`, `@file:`, pasted URLs.
Every kind painted the raw primary in both modes; on a dark surface that is
the thinnest colour in the palette and the reporter saw @mentions vanish.
Declare the fallback first (`.ref, [data-ref]`) so the kind rules outrank it
by order, exactly as the INLINE REFERENCES block documents. An unkinded
reference still keeps the primary link colour.
Test resolves the cascade for a real `<span class="ref" data-ref=…>` against
the compiled stylesheet (Tailwind's `@supports` color-mix fallback included).
Answering an approval with the pointer moves focus onto the card's button,
and the card then unmounts, which parks focus on <body>. From <body> the
type-to-focus keybind routes the next printable keystrokes into the chat
composer. In a computer_use flow the agent has typically just clicked the
embedded terminal (or preview) pane, so its follow-up `type` action landed
in the composer while the tool reported success (#113839).
The card records document.activeElement on pointerdown (before Chromium
moves focus to the button) and, once the reply is committed, focuses that
element again — only when focus is still stranded on the card or <body>,
never when the user has since moved to another surface. The modal prompts
(sudo / secret / vault) already get this from Radix Dialog's focus return;
the in-transcript approval card is the one surface that did not.
The Settings unarchive prepended the row into $sessions regardless of
source, so a messaging or cron session showed under SESSIONS until the
next refresh. Route it through restoreListedSession, which picks the slice
from the row's source.
upsertResolvedSession now evicts the moved row from the slices it does
not belong to. `prev.filter` always returns a fresh array, so every
row open would have repainted the messaging and cron sections even when
neither held the row; return `prev` when nothing matched, matching the
signature-gated swaps the list refresh already uses.
upsertResolvedSession always prepended to the regular sessions slice, so a
resolve that observes a source move (cross-room resume rewrites the row to
source=matrix) showed the session twice. Share the slice targeting with
restoreListedSession and evict the stale copy from other slices.
Fixes#113827.
The three "still preserves" cases are already pinned on main by
resume-structural-parts.test.ts (live flat row keeps cached structure,
#76444 non-extending dump) and utils.test.ts (#75825 empty-shell
preference), so they were change-detectors here. Also drops an
unrelated blank-line hunk in appendLiveSessionProjection.
Co-authored-by: KoNit-K <konit.block@protonmail.com>
The inline-code token rule keyed on the assistant-message slot, so every
other consumer of the same `aui-md prose` renderer — system-message
background reports (#107486) and the session-import preview — fell
through to Tailwind Typography's fixed near-black ink on dark themes.
Widen the selector to `.aui-md :not(pre) > code` so each consumer
inherits the tokens from the renderer root; its (0,1,2) specificity
still beats `.prose :where(code)`, and the unlayered rule still beats
Streamdown's layered utility background. The room selector stays for
the plugin's raw-Streamdown fallback, which has no `.aui-md` root.
Adds one cascade test on the system-message surface (real stylesheet,
getComputedStyle on the opened report's <code>): red before, green after.
Salvages the renderer-root selector from #107492 (@KoNit-K).
Replace the salvaged source-text regex test with a jsdom render of the
room workspace: styles.css is injected as a real <style> sheet and the
assertion reads getComputedStyle() of the <code> the shell renderer emits
inside a room body. A regex over the CSS source passes for any rule that
mentions the tokens, whether or not its selector reaches the room's DOM;
the cascade test is red when the room-owned selector is missing and green
only when it actually matches (verified by swapping styles.css to
origin/main).
Convert every `<button>`/`<Button>` that carried a native `title=` under
apps/desktop/src, per the DESIGN.md rule (tips only where hover teaches
something new; otherwise `aria-label` for a11y):
- `aria-label` swap where the visible affordance already teaches the action
(zoom/lightbox triggers, delete icon, install "+", reference thumbnail).
- `title=` deleted where an equal `aria-label` or visible text already exists
(remove/disconnect trash icons, send arrow, stop glyph, Download button,
MCP tool chips with `aria-pressed`, Lock face + its caption, pet toggles,
spectator bubble with `aria-expanded`, Create Group whose dialog copy already
says "Pick 2–N bots" — a tip on a `disabled` Button never opens anyway).
- `<Tip>` where hover reveals something not on screen: full path on changed
files rows (`key` on the Tip inside the map), "Restore checkpoint — rerun
from this prompt", "Disconnect (runs the removal command in the terminal)",
group-chat attach hint and the speaker-handle toggle.
Guard hardening on top of the salvaged scanner: skip `//` and `/* */` comments
inside the opening tag (an apostrophe in `// Don't steal focus…` between
attributes otherwise opens a phantom string and hides `title=` — two shipped
sites were invisible that way), skip `*.test.tsx` fixtures, and match `title=`
as its own attribute (not `data-title=`). One fixture test pins those cases.
Co-authored-by: wnuuee1 <poli.koltsova@gmail.com>
The guard's tag regex stopped at the first ">" (inside `onClick={() =>`)
and its scan root resolved to src/components, so no site was ever flagged.
Salvaged from #113717 (guard hunk only).
The lifecycleKeepAlive gate in watchPreviewTileMirror had no test: swapping
preview-tile.tsx back to main left the suite green, so a regression that
dropped the flag (and turned Hide back into an unmounting Minimize for the
in-app browser) would have shipped silently. preview-tile.test.ts now drives
the real mirror and asserts a url tile registers with lifecycleKeepAlive=true
while a text file peek stays evictable.
The Hide label and kept-mounted hidden body key on lifecycleKeepAlive for every
pane, and the Terminal pane sets it too; the user guide only mentioned the
browser, so the sentence now covers both.
The salvaged kernel kept EVERY minimized zone's panes mounted, contradicting
the deliberate park-on-hide budget documented in tree-group.tsx. Only panes
that declare `lifecycleKeepAlive` (embedded Browser / HTML preview, terminal)
stay mounted while their zone is hidden; everything else unmounts exactly as
before, and a hidden zone with no keep-alive tenant renders no body at all.
- keep the reconciler's lifecycle value for visible zones; a hidden zone
reports `hot-hidden` to its kept panes instead of a hard-coded rewrite
- PaneBody rides the app's ONE shared ResizeObserver
(hooks/use-resize-observer.ts) instead of a private observer per zone
- one invariant test: keep-alive pane survives hide/restore with state and
the "Hide" label, plain sibling still parks (red on origin/main)
- docs: Hide/Restore vs Close for the in-app browser
Separate Hide/Restore from Close for the embedded Browser: a minimized
zone keeps its body mounted (PaneBody retains the last visible viewport,
visibility:hidden + inert) instead of unmounting the <webview>, url/html
preview tiles opt into lifecycleKeepAlive via PaneMirror, the collapse
action reads "Hide" for keep-alive panes, commandFocusedPreview ignores a
hidden guest with stale native focus, and drive_preview's focus() no
longer steals the host composer while the pane is hidden.
Salvage-TRIM of #114251 (kernel only). Dropped from the original:
DESIGN.md, the apps/desktop/scripts/browser-hide-restore/ Electron smoke
harness, the three-layer host-focus bounce around executeJavaScript /
sendInputEvent / focusin in preview-pane.tsx, and the extra test files
(pane-body.test.tsx, preview-script-runner.test.ts, preview-tile.test.ts,
two of three tree-group cases).
Do what the follow-up said: drop the duplicate ToolActivityImage resolver
(a copy of MarkdownImageContent's effect that rendered nothing on failure)
and reuse MarkdownImage, which owns resolution, the loading/'Couldn't load'
states and the lightbox. Adds the failure-state invariant.
Follow-up to the salvaged commit: instead of a new resolver component in
fallback.tsx (a second copy of MarkdownImageContent's resolve/cancel
effect), render the activity image with the existing MarkdownImage. It
already owns the local/remote file-bridge resolution (readFileDataUrl
locally, profile-scoped /api/fs/read-data-url on a remote gateway), the
loading and "Couldn't load" states, and the shared lightbox.
Rendered regression: a completed vision_analyze row with a text-only
native-vision receipt shows its disclosure, resolves the image through
the right bridge for the connection mode, and opens the lightbox.
Docs: mention expanding an image-bearing activity row.
`444c75c10a` rendered `titleBar.center` in the workspace panel header on
full pages so the kanban board switcher would sit beside the page title
instead of colliding with the sidebar tab strip. That made the area migrate
between two React subtrees on chat <-> page navigation: the old instance's
effect cleanup ran after the new instance's setup and wiped every global
side effect a third-party plugin had just re-created (#114290).
Keep both intents: `titleBar.center` is a permanent titlebar slot again (the
salvaged commit), and page-owned controls move to a dedicated
`WORKSPACE_PAGE_HEADER_AREA` (`workspace.pageHeader`) that the workspace
pane projects into its vetoed tab row while `$workspaceIsPage` holds —
exactly the placement `444c75c10a` introduced, just not through the
plugin-facing titlebar area. Kanban's board switcher contributes there.
- SDK exports `WORKSPACE_PAGE_HEADER_AREA`; `TITLEBAR_AREAS` documents the
permanent-mount contract.
- Test: a `titleBar.center` component's effect runs setup once and cleanup
never across a chat -> /skills -> chat round trip (red on base).
- Docs: plugin SDK guide covers the lifecycle contract and the page-header
area.
444c75c10a moved the slot between the titlebar and the workspace header
on chat ↔ page navigation. Plugin useEffect cleanup on the old instance
then wiped the new instance's global side effects. Leave the slot in the
titlebar on every route.
The attach-refusal message framed an unimplemented capability as a property
of the live owner ("does not advertise cooperative attachment"), sending users
looking for a setting that does not exist. Follow the shared owner-refusal
contract instead: first line states the chat is open elsewhere and names the
working alternative (close it there, hermes --resume <id> here), second line
keeps the Details: owner summary. Same shape when the handshake itself fails.
Fixes#113457
(cherry picked from commit 78966577102f3db1383a162f526394d8af757a38)
The `--run-delivery` catch-all in tools/bot_mode_dm.py printed a structured
`{error, reason}` only for target_busy; any other exception went to stderr as
prose, so the sender's completion notification had no reason to branch on
(auth failure vs. transient) for the local lane while the relay lane had one.
The vocabulary-guarded helper the relay lane introduced moves beside the
vocabulary it guards (tools/bot_failure_reasons.delivery_failure_reason) and
both lanes call it: an exception's own `reason` is trusted only when it is
`target_busy` or in ALL_REASONS, otherwise the text is classified. The runner
now prints `{error, reason}` JSON on stdout for every exception (exit 1 kept).
Test: a classifiable, an unclassifiable and an out-of-vocabulary-reason
exception in --run-delivery each yield JSON with a vocabulary reason.
main now treats a bare profiles/<name>/ dir as not-a-profile (bot_relay.deliver
answers 4092 before reaching the refusal exits). The salvaged #113534 test
predates that gate; touch config.yaml like the sibling deliver tests do so
the six refusal rows reach the branches under test.
The Bot Mode guide states the contract: a failed relay delivery carries a machine-readable reason
end to end, and it names both `delivery_timeout` and `target_busy` among the codes the target
gateway classifies and the Desktop forwards. Only the turn-failure branch did that. The timeout
branch and the catch-all — which is where a bounded-wait `target_busy` refusal lands — returned a
JSON-RPC code and prose with no `data`.
`data.reason` is the only channel that survives the trip. The Desktop reads `error.data.reason`
and writes it into the sender's reply file, and when it is absent the sender re-classifies from
free text, which has no rule for either code. So the one refusal a sending agent most needs to
branch on, "that teammate is busy, retry shortly", arrived as `[reason: unknown]`, indistinguishable
from an auth failure, and the Desktop's needs-attention badge fell back to prose. The local lane
has always reported `target_busy` properly, so the two lanes disagreed.
The reason stays inside the documented vocabulary. An exception's own `reason` is trusted only when
it names a real code: TurnBusyError carries `target_busy`, but `reason` is also a stdlib attribute
on ssl.SSLError and urllib.error.URLError, and forwarding one of those would put free text where
consumers expect a closed set. Everything else is classified like any other failure. That guard
comes from JoaoMarcos44's #113602, which reached the same fix independently.
This also gives `delivery_timeout` its first producer: the code was defined, listed in ALL_REASONS
and marked auto-retryable, but nothing ever emitted it.
One table over the refusal branches. Dropping either `data`, trusting an out-of-vocabulary reason,
dropping the target_busy case, or never trusting the exception each fails its own rows.
(cherry picked from commit 6bbb047be1b30ef445cb11149d9e787012217af6)
The Bot Mode guide states the contract: a failed relay delivery carries a machine-readable reason
end to end, and it names both `delivery_timeout` and `target_busy` among the codes the target
gateway classifies and the Desktop forwards. Only the turn-failure branch did that. The timeout
branch and the catch-all — which is where a bounded-wait `target_busy` refusal lands — returned a
JSON-RPC code and prose with no `data`.
`data.reason` is the only channel that survives the trip. The Desktop reads `error.data.reason`
and writes it into the sender's reply file, and when it is absent the sender re-classifies from
free text, which has no rule for either code. So the one refusal a sending agent most needs to
branch on, "that teammate is busy, retry shortly", arrived as `[reason: unknown]`, indistinguishable
from an auth failure, and the Desktop's needs-attention badge fell back to prose. The local lane
has always reported `target_busy` properly, so the two lanes disagreed.
The busy refusal already carries its own reason on the exception, so it is reused rather than
re-derived, and other exceptions fall back to the same classifier the turn-failure branch uses.
This also gives `delivery_timeout` its first producer: the code was defined, listed in
ALL_REASONS and marked auto-retryable, but nothing ever emitted it.
One table over the refusal branches; dropping either `data`, ignoring the exception's own reason,
or pinning a constant each fails its own rows.
(cherry picked from commit 21cf42db4b9cd145995ae6979971857a89958367)
Only the two overlay sites consulted `projectOwnerBySessionId`; the flat
list filter (`sessionMatchesProjectFilter`), `sessionBucketId`, the color
(`sessionProjectColor`) and the card label (`sessionProjectLabel`) still
derived ownership from the cwd/git_repo_root longest-prefix walk. A sibling
worktree row (cwd `/work/repos/app-2`, `git_repo_root` NULL) that the
backend tree assigns to `/work/repos/app` therefore still matched the
ANCESTOR project `/work` in the Sessions list, inherited its color/label
and was bucketed under it — disagreeing with the lane grouping.
`liveSessionProjectId` (and the thin classifiers above it) now take the
owner map and return the backend owner before the cwd walk; a new
`$projectOwnerBySessionId` computed store derived from `$projectTree` is
the single authority every consumer (filter, bucket, color, label,
store/projects, entered-project overlay) reads.
Build on #114643 (@KoNit-K): the renderer now keys the live overlay on an
authoritative owner map, but that map was derived from the overview tree's
`previewSessions` (capped at 3) plus hydrated lanes (empty in overview mode),
so any owned row beyond the preview window still fell back to the cwd walk
and re-appeared under an ancestor project.
- `projects.tree` nodes now carry `sessionIds`: every row `build_tree`
assigned to the project (contract + regenerated shared TS/OpenRPC).
- `projectOwnerBySessionId` reads `sessionIds` first, keeping the row-derived
fallback for a backend that predates the field.
- `index.tsx` imports the helper the pick referenced (typecheck failed on
the contributor head) and tolerates an undefined tree.
- Tests: the exact issue layout (/work explicit, /work/repos/app explicit,
cwd=/work/repos/app-2) with git_repo_root null AND populated; the entered
ancestor gets nothing, the entered repo shows the linked-worktree lane, a
row the tree does not know still lands by cwd; the backend keeps the
claimed set complete in overview mode.
The live pass (headless Electron on the composite) still rendered every
composer tooltip visibility:hidden: the pane-resolving layout effect ran
once at Tip mount, before the composer trigger had geometry, so the
zero-rect display:contents host was kept as the collision boundary and
never re-resolved. Move the resolution into a component rendered inside
the Radix Portal, which mounts only while the tip is open — the trigger
is laid out (it is being hovered) and the boundary is re-resolved on
every open.
Invariant: tooltip-pane-layout.test.tsx 'resolves the pane at open time,
not at mount' (trigger zero-rect at mount, laid out before hover) — red
before, green after. Re-driven live: Add context / Model tooltips now
visibility:visible, collisionBoundary = the enclosing flex grp-main pane.
Follow-up to the salvaged fix: instead of dropping straight to the viewport
when the closest `[data-tree-group]` is a `display: contents` host (the
floating-composer portal), walk up to the enclosing tree-group that has
layout, so docked composer controls keep the pane boundary the placement
table intends; only a fully layout-less chain falls back to the viewport.
Tests trimmed to two invariants: the popper wrapper is not hidden when the
nearest host has no layout (real Radix), and the boundary resolves to the
enclosing pane past a zero-rect host (placement suite).
When the peer set drops to one connection there is nothing left to relay, but the surviving
gateway still holds the last roster it was pushed — so the departed machine's agents stay in every
bot's prompt and stay resolvable as message_agent targets. The loop pushes an empty roster once per
sole connection to make it forget, and marks that connection cleared.
It marked it cleared before the push, and swallowed every failure under a comment describing only
one of them. `host.requestProfile` also throws when the socket is not up yet, which is exactly the
moment a machine has just left. One failed push therefore spent the one-shot for good: the guard
compares against the id already recorded, so no later tick ever tries again and that gateway keeps
the dead machine's agents for the life of the Desktop.
Record the clear only once every gateway has actually forgotten. A push that fails leaves it
unspent and the next roster tick retries.
The test fails the push once, asserts the next tick retries it, and asserts the one-shot still
holds after it lands. Spending it early, or on partial success, each fails it; never spending it
fails the existing one-shot tests too.