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.
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: []`.
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.
The salvaged test returned early off macOS, so Linux CI never executed the
assertion. Stub process.platform to 'darwin' (execFileSync is already mocked)
so the `--sdk macosx clang` argv contract is checked wherever vitest runs, and
add the off-macOS no-op as the control case.
A bare xcrun clang inherits the host default SDK, which may be newer than
the active linker (unknown-arch .tbd stubs at link time). Naming --sdk
macosx explicitly forces the driver and linker to agree on one SDK.
Fixes#113708.
(cherry picked from commit d5fcb6c6ab3de96602528dade2d7f75b7e47ddd5)
When the backend counts rows before an open history page but the prompt
index is complete and names no predecessor, revealOlder returned false
without touching state, so the button and the top-edge auto-page kept
offering a page that could never arrive. Retire the page's reach in that
case; an incomplete index still leaves the page untouched for a retry.
Tests: one new history-window case pins the retire; the session-top
control (already covered by the reachability case's final assertions)
and the separate lookup-ceiling case are folded away, and the two
padding-line lint warnings in history-window.test.tsx are fixed.
The around route reads forward from a prompt, so when a single turn holds
more display rows than the page limit, the previous prompt's page ends before
the open window's first row. Merging the two would paint one continuous
transcript with a silent hole. Each page now carries `pagination.offset`
(display rows before its first row); an older page that does not reach the
current offset replaces the display page instead of being prepended — the
reader still moves backwards, nothing is omitted without notice — and the
prepend scroll anchor is not spent for a replacement.
Docs: the desktop guide now says Show earlier keeps paging back after a
timeline jump.
2efbbef981 read "there is more above" from the live-tail flags alone:
const olderAvailable = !history.page && (windowed || restBackfillAvailable)
A rail jump replaces the display with a bounded around page, so every row that
page does not hold reads as exhausted. The transcript's "Show earlier" button,
the top-edge auto-page, and the parked-offset regrow all key off that single
`olderAvailable`, so a reader who clicked an older mark was pinned to that mark
and everything after it — while the around window itself had already reported
`has_older: true`.
The window now carries its own reach (`pagination.has_older`), and "Show
earlier" pages it backwards through the same prompt index the rail draws
(previousPromptRowId; cached and coalesced with the rail's own loadMore)
instead of refusing every prepend. One range, so the rail's marks and the
transcript's entry point cannot disagree about what is still up there.
The rail's earlier mark also stops depending on the live window flag for its
reach: with the index not yet loaded it pages the transcript instead of
falling through to a no-op reveal.
A suppressed identical re-record still bumps the key to the MRU end of
transcriptTailOrder (no store publish), so the constantly re-read active
session is not the first eviction candidate once 256 distinct tails were
hydrated. Also pads the statements eslint flagged in the tail tests.
Two more atoms of the class #113845 opened (#113842):
- useStatusSnapshot published a fresh StatusResponse object on every 60s tick
even when the content was unchanged, re-rendering every consumer for
nothing. Content-equal snapshots now keep the previous reference.
- The transcript restore effect in thread/list.tsx re-pinned a `bottom` target
on EVERY ResizeObserver tick once settled (every composer keystroke resizes
the content box), and its cancel set (wheel/pointerdown/keydown) did not
include `scroll`, so a rail jump, a find-in-page reveal or a scrollbar drag
never ended the restore — the next tick yanked the view back to the bottom.
The bottom branch now re-pins only on real transcript growth
(shouldReapplyFrozenThreadScrollOffset handles both target kinds), and a
`scroll` whose position neither this effect nor use-stick-to-bottom wrote
cancels the restore. The effect reads back every scrollTop it writes so its
own scroll events cannot cancel it.
Tests: content-equal status re-read keeps identity (red on base); jsdom
list-restore cases for the scroll cancel and the composer-only resize gate
(red on base); the pure predicate's bottom contract.
Heartbeat re-records identical tail entries; each unconditional .set()
re-rendered the whole chat tree through the tail atom. Content-compare and
return early on no-ops.
Fixes#113842.
disposeSecondariesForConnection re-dials an unleased active scope straight
away, through the same dispose → evict → ensureGatewayForAgent sequence the
drain path uses, so it has the same window: the entry is out of the map while
ensureGatewayForAgent awaits its shared-remote probe, and a prune in that gap
takes the activation through setActive(primary) and the redial's epoch check
discards it. Route both call sites through reopenAfterRedial so the
reactivating marker covers the whole class, and pin the sibling with a second
invariant test.
Editing the connection you are viewing defers its redial until the last lease drops, then disposes
the entry, evicts it and re-activates the same scope asynchronously. While that is in flight the
entry is gone from the map, so restoreActiveToPrimaryIfEvicted sees an active scope with no entry
and calls setActive(primary) — which bumps the activation epoch, and the redial's own
applyActive(epoch) is then refused. The socket it dialed is fine; the window just never goes back
to it. A connection edit silently re-homes the window to the primary backend.
Mark the scope while its re-activation is in flight and let the safety net skip it. The marker is
cleared in a finally on both paths, so a redial that never lands still falls back.
The test holds the redial inside a gated dial, runs the pruner in that window, and asserts the
window ends up on the re-dialed socket rather than the primary. Letting the pruner ignore the
marker fails it.
The titler receives the opening message AFTER @-reference expansion, so
the Desktop's generated pasted_content @file: ref arrives with a
'--- Context Warnings ---' (or '--- Attached Context ---') footer. That
footer made the ref-only check in build_title_input fail, so the live
wire capture showed the path + warning leading the title-model input
with 'Pasted content: ...' appended after it. Strip the expander footer
when a preview is present, so a paste-only opener lets the preview lead.
Invariant: test_expanded_paste_ref_footer_does_not_demote_the_preview
(red on a5dac801, green here). Re-checked on the wire against the stub
model: title input now starts with the pasted topic, no @file:/warning.
The PR widened tui_gateway/methods_prompt.py::_run_after_agent_ready with a
display_metadata positional; tests/tui_gateway/test_submit_time_user_row.py
(already on main) still called the six-arg form and failed CI with a
TypeError. Update the call.
Add one turn_context-level invariant: a user message carrying
display_metadata.title_preview reaches maybe_auto_title(title_preview=...).
Red with agent/turn_context.py swapped to origin/main, green on this head;
without it the whole prompt.submit -> display_metadata -> titler seam had
no test.
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).