Commit Graph

3396 Commits

Author SHA1 Message Date
Teknium 334bcbac93 fix(desktop): error card honors the classifier's retry verdict + failing-session identity (review feedback)
Addresses @helix4u's review on #91493:
- conversation_loop now stamps failure_retryable (the real ClassifiedError
  verdict) next to failure_reason; error_surface prefers it and only falls
  back to the reason set for older results. Fallback set corrected to match
  classify_api_error (auth, format_error, billing_unverified now
  non-retryable).
- The descriptor carries the failing session's provider/model captured at
  classification time; Copy error details prefers them over the foreground
  composer atoms.
- Open logs is labeled 'Open Desktop logs' on remote/cloud connections —
  the local folder holds transport logs, not the remote runtime's.
- API-exception module allowlist widened to botocore/boto3/google/grpc/
  requests/aiohttp so other adapter SDKs don't misclassify as gateway.
2026-08-21 15:24:03 -07:00
Teknium 50f1e414bc polish(desktop): rename error-card action to 'Copy error details'
'Copy diagnostics' was dev-speak; match the familiar OS-error phrasing.
All five locales + docs updated.
2026-08-21 15:24:03 -07:00
Teknium 3903428a72 Revert "feat(desktop): error card offers Nous support link on Portal-auth sessions"
This reverts commit 31872bfcf555cedb2501122a75e29328c0e90e80.
2026-08-21 15:24:03 -07:00
Teknium e3d46bb5fb feat(desktop): error card offers Nous support link on Portal-auth sessions
Sessions running on provider 'nous' get a 'Nous support' action on the
failed-turn card, opening the portal help hub
(https://portal.nousresearch.com/help — docs, Discord, GitHub) in the
external browser. All five locales + docs updated.
2026-08-21 15:24:03 -07:00
Teknium 892790f980 fix(desktop): error card renders router-free threads without crashing
useNavigate() throws outside a <Router>; streaming.test.tsx renders the
thread bare. Move the Settings deep-link into a SwitchProviderAction child
gated on useInRouterContext(), which is safe in any tree.
2026-08-21 15:24:03 -07:00
Teknium 98f6fc549a feat(desktop): failed turns name the failing layer with recovery actions
Turn errors now carry a structured {layer, code, retryable} descriptor
(agent/error_surface.py) built from the same classifier the retry loop
uses. The tui_gateway stamps it on terminal error frames, retained
failed-turn snapshots, and resume replay; the Desktop error card renders
the layer title (provider / endpoint / streaming / auth / billing /
gateway / runtime / disk) plus matched actions: Retry, Switch provider,
Open logs, Copy diagnostics.

Older backends that omit the descriptor keep today's behavior (generic
title, string-sniff fallbacks) — the field is advisory on both sides.
2026-08-21 15:24:03 -07:00
Teknium bd93a5f316 feat(models): free models show star + -100% in the model picker discount column
Free ($0/$0) Nous Portal models sat with a blank discount column and no
sale star (stealth/ox-alpha, upstage/solar-pro4:free), reading as missing
data next to the -20% sale rows. compute_sale_discount now returns a flat
100% for free models; was_* raws pass through only when the gateway served
a pricing.original, so natively-free models render bare '-100%' with no
fabricated 'was ?/?'. CLI picker star follows on_sale automatically;
inventory feed carries discount_percent=100 to Desktop, whose FREE badge
row now renders the amber -100% pill beside it.
2026-08-21 14:38:41 -07:00
Teknium 1bf8bd2c7d feat(models): 'ox alpha' now finds x-preview-f-free in every model picker
The OpenCode Zen wire slug for the Ox Alpha stealth model is opaque
(x-preview-f-free); users searching the picker for 'ox' or 'ox-alpha'
found nothing. Adds the search alias across all four synced alias
tables (CLI, desktop, web, TUI) plus tests. Wire id is unchanged and
still what renders and gets sent to the provider, matching the k3 →
kimi-k3 precedent. No canonical-dedup collision with opencode-go's
keyed ox-alpha-free slug.
2026-08-21 14:38:19 -07:00
Minsang Lee 0287dfb0c2 fix(bot-mode): a bot row opens the conversation you were last having
Clicking a bot in the roster always reopened its pinned canonical Bot Chat.
Start a new conversation with bot A, click bot B, click back to A — the new
conversation was gone, replaced by the pinned transcript. A bot row is a
workspace entry point, so it has to land on the live conversation.

Two independent causes, both fixed here:

1. The pin overrode newer work.
   `openBotCanonicalChat` opened the pin unconditionally. It now prefers the
   bot's freshest VISIBLE session — but only AFTER `profiles.list` has
   verified through `preferred_session` that the pin is alive and is a real
   canonical Bot Chat. That ordering matters: with a dead or unverified pin,
   adopting the profile's latest row would claim an unrelated user
   conversation as the bot's chat, and the hide sweep would then hide it.
   The existing "no pin" / "dead pin" safety tests cover exactly that and
   still pass. The pin keeps owning plumbing (creation, hide sweep, DM
   delivery); it just stops shadowing newer conversations.

   Guards on the candidate (`newerVisibleBotChat`): the canonical chat can
   never shadow itself, an empty draft never displaces a real conversation,
   and a gateway that omits `message_count` is treated as real history
   rather than discarded.

2. The workspace did not follow the bot.
   The three `host.openSession` calls on the bot path relied on the SDK
   default `keepAllProfilesScope: true`, so `$activeGatewayProfile` stayed on
   whatever profile was active before the click. Sessions created afterwards
   were then filed under the previous bot's profile — measured: four new
   chats started from three different bots all persisted into one profile's
   state.db. Clicking a bot IS a profile switch, so these pass `false`.

Note on the call shape: `previewSession` is `bot.preferred_session || last`,
so on a pinned bot it resolves to the PIN (preview identity must match click
identity). Feeding that as the "newer" candidate makes the whole preference
dead code — it always sees the pin and short-circuits on "same id". The
freshest visible session therefore arrives as its own argument. The first
attempt at this fix had that bug and passed its tests, which is why
`bot-row-opens-latest.test.mjs` mirrors the production call site argument for
argument rather than constructing a convenient one.

Tests: 362 pass (was 348). Each new guard was verified by sabotage — reverting
any one of the three behaviours above makes the suite fail (1, 3, and 1 tests
respectively), so none of them is a test that passes either way.
2026-08-21 13:42:54 -07:00
ethernet 9815319d5f refactor(desktop): derive the tab hover close button from the close verb
PaneTab gated its hover close button on two independent inputs: the
onClose verb, and a showCloseButton prop that TreeGroup fed from a
showCloseButton flag on the pane contribution. The middle-click and
Meta-click gestures read only onClose. A tab could therefore close on a
pointer gesture and advertise no control for it.

The flag had no user that hideOnly did not already cover. Both setters
also set hideOnly: true, which removes every close gesture:

- the sessions pane (app/contrib/controller.tsx),
- the Bots pane (plugins/hermes-bots/plugin.js).

The flag was an opt-out marker with no reachable effect, so this change
deletes it instead of teaching it to track the gestures. onClose alone
now decides both shapes. A tab that closes shows the button. A tab
without the verb shows nothing. To make a tab uncloseable, give it no
close verb.

hideOnly and uncloseable keep their meaning. They gate the verb, and
both shapes follow the verb together.

The DialogContent and SheetContent prop of the same name is a different
prop and stays. It has no close verb to derive from, and one caller
changes it while the dialog is open.

Tests: the new tab-close-affordance test renders the real TreeGroup and
asserts that button presence equals middle-click closure. It covers
hideOnly chrome, a plain side pane, the uncloseable workspace, and a
session tile. It reads closure from the layout tree, not from a spy, so
a wired-up mock cannot pass it. A regression that hides the button on a
closeable tab fails two of the four cases. The compiler rejects the
deleted prop, so the test carries no fixture for it. The pane-tab unit
test moves off the deleted prop.

Verified with the full apps/desktop vitest suite, npm run typecheck, and
npm run lint. Two electron process-spawn tests fail on this machine.
They also fail on a clean tree, and they do not touch the pane shell.
2026-08-21 16:04:25 -04:00
Teknium a2da0ab797 feat(cron): bot-chat delivery target — cron output lands in a bot's canonical Bot Chat and the bot responds
deliver='bot-chat[:<profile>]' is a machine-local pseudo-platform: the
scheduler delivers job output as a real inbound turn in the target
profile's canonical Bot Chat via the chat CLI lane (--in ~ -c "Bot Chat"
--create-if-missing -Q --query-file), the same lane Bot Mode
agent-to-agent messages use. The bot reads the output, acts on it, and
responds in its chat — instead of the output only landing in Run history.

- cron/scheduler.py: token parsing, target resolution (own profile /
  named local profile / unknown -> skipped with warning), subprocess
  delivery lane with cron.bot_chat_delivery_timeout_seconds (default
  600s), preflight exemption, and bot-chat entries in
  cron_delivery_targets() for UI pickers. Excluded from 'all' by design.
- tools/cronjob_tools.py: create/update-time validation — named profiles
  must exist on this machine (fail at create, not at 3am); deliver schema
  documents the new token.
- tui_gateway/methods_tools.py: cron.manage add forwards deliver.
- hermes_cli/profiles.py: list_profile_names() cheap name-only scan.
- hermes-bots plugin: Create Cronjob dialog gains a 'Send results to'
  picker (Run history only / <bot>'s chat); bot-chat jobs send the BARE
  token on the profile-scoped create so Desktop-side aliases can never
  name a profile the backend doesn't have.
- Docs: user cron guide, automate-with-cron, cron-internals.

Machine-local by construction: names resolve only against the executing
machine's ~/.hermes/profiles/, so overlapping profile names across
multiple connected gateways are unambiguous.
2026-08-21 12:48:53 -07:00
Brooklyn Nicholson 272b007f8c feat(desktop): give hiding the tab strip a command, and a way back
The strip could only be hidden by an undiscoverable double-tap, and once hidden
the zone had no chrome left to click — no tab, no ✕, no menu holding "Show".
This puts it on the same footing as the status bar, whose hide has never
stranded anyone: ⌥⌘T, a ⌘K row, the shell context menu, and the zone menu, which
now prints the keystroke on the row that takes the strip away so the way back is
stated at the moment it matters. All four resolve their target zone the same way
the other tab verbs do (hovered, else focused, else the workspace) and describe
themselves from what is on screen rather than from a stored value, so "toggle"
always means the opposite of what the user is looking at.

Adds an app-wide default alongside it, in Appearance next to Session List
Density — auto, always, or never, matching VS Code's `workbench.editor.showTabs`
and Zed's `tab_bar.show` for people who want one answer everywhere instead of a
per-zone choice they repeat. A zone that has stated its own preference still
wins, and neither value can strand a pane.
2026-08-21 13:25:29 -05:00
Brooklyn Nicholson 315307f139 refactor(desktop): make a zone's tab strip a stated mode, not a flag five paths wrote
`headerHidden` carried two meanings at once. `true` was either "the user hid
this" or "a double-tap nobody meant hid this"; `false` was either "the user
wants a strip" or "insert / tab-cycling / dock-enforce / adoption pinned one to
escape a dead end". Because the layout wrote the same field the user did, a
repair silently overwrote a preference and neither could be read back — and
since hiding also unmounted the tab, the ✕ and the menu offering "Show header",
a zone that got hidden by accident stayed that way across restarts.

Replaces it with `tabStrip?: 'always' | 'never'`, where absent is auto and only
the user ever writes it, and moves the decision into one resolver that TreeGroup
and the store both call, so the strip on screen and the toggle command cannot
disagree. Reachability moves into that resolver as an invariant that outranks an
explicit `never`: a closeable tile keeps its ✕ and a lone tool panel keeps its
chip, because "hide the chrome" is never a request to make a surface
unreachable. With that guarantee held centrally, the four repair writes are
gone. Persisted `headerHidden` is dropped rather than translated — nothing on
disk distinguishes a deliberate hide from an accidental one, and carrying the
accidents forward would re-strand exactly the people who reported being stuck.

The double-tap hide goes with it, along with the synthesized double-tap detector
it was the only consumer of. It fired from ordinary double-clicks on a tab,
nothing announced it, and its undo lived behind the chrome it had just removed.
`data-zone-no-header` goes too: it marked full-page views for a body
double-click toggle that no longer exists, and nothing has read it since.

Supersedes the tab-side half of the fix from abundantbeing and yoniebans, whose
commits this builds on.
2026-08-21 13:25:15 -05:00
yoniebans 001a4c91c6 fix(desktop): scope the salvaged fix to the failure-path removal
Narrows #86278 to exactly the defect. Tabs pass no double-tap context on any press path (generic pane drag, multi-tab selection drag, chrome.tabDrag), so a double-click on a tab can no longer hide the strip; the strip background keeps its documented hide gesture unchanged.

The body double-tap reveal from #86278 is dropped: the zone body deliberately carries no double-click gesture (virtualized content recreates its nodes between clicks, per the standing ruling in tree-group.tsx), and recovery surfaces for a deliberately hidden header are being decided separately across #84458 / #81638 / #89225. The DOUBLE_TAP_MS export is reverted since no consumer remains outside drag-session.

Test file trimmed to the two assertions that pin the grammar: a tab double-tap must not hide the strip (red on main), the strip background double-tap still hides. Taps release on window between presses so the drag-session synthesized double-tap path is the one exercised.
2026-08-21 12:49:15 -05:00
abundantbeing 3aeb592863 fix(desktop): stop tabs double-click-hiding the tab strip; body double-tap reveals it
The synthesized double-tap that hides a zone's tab strip rode every tab's
pointerdown (generic pane drag and each pane's tabDrag), so a routine
double-click on a tab (select a title, retry a click) vanished the whole
bar and stranded the zone with no tab, no close X, and no way back but a
right-click. Keep the documented hide gesture on the strip background
only, and add its inverse as recovery: double-tap a hidden zone's body
restores the strip. Regression tests pin both sides of the grammar.
2026-08-21 12:49:15 -05:00
emozilla f33b260afa fix(desktop): boot overlays stay opaque under window glass
The full-screen boot surfaces (connecting, onboarding, boot failure, root
crash fallback) paint their backdrop with --ui-chat-surface-background,
which the glass field turns transparent so <body> can be the one painter
(0483133842). That was harmless while glass shipped off; once it shipped
on by default (be3166607e) every boot overlay became a window onto the
shell behind it.

These overlays mask the whole app, so they declare data-glass-opaque —
the existing contract for surfaces that paint over siblings — which pins
the token back to opaque chrome under glass and changes nothing when
glass is off.
2026-08-21 11:54:51 -05:00
pierrenode fb7f0602fb fix(bot-mode): durableGroupChatRooms drops tombstones and roomId on the remote-merge persist path
updateGroupChat's inline durable-map builder (the local-mutation persist
path) skips tombstoned rooms and carries roomId — but durableGroupChatRooms,
the SEPARATE builder persistGroupChatRooms uses for the remote-merge path
(every pullGroupChatServerState / gateway-swap sync), has neither.

Two independent gaps in the same function:

1. Tombstone resurrection. Disband sets a runtime-only tombstone
   ({tombstone: true, log: [], ...}) while a drive may still be mid-turn,
   with no roomId. mergeRemoteGroupChatSnapshotIntoRooms spreads
   ...existing before its explicit field overrides (none of which touch
   tombstone), so if a remote gateway hasn't received the delete yet
   (plausible now that sync fans out to every reachable default-profile
   gateway with independent per-connection backoff) and still has a live
   copy of the room, the tombstone flag survives into the merged room.
   That merged map is handed straight to persistGroupChatRooms, which
   wrote it to storage because durableGroupChatRooms had no tombstone
   check. On the next cold hydrate the persisted tombstone reads back as
   an empty, non-tombstoned room, resurrecting the original bug
   (recreating a room under the same name silently becomes "<name> 2")
   through a path the earlier tombstone fix didn't cover.

2. roomId loss. mergeRemoteGroupChatSnapshotIntoRooms correctly carries
   roomId into the merged in-memory room, but durableGroupChatRooms never
   included it in the persisted snapshot. Every room merged in via the
   remote-sync path therefore loses its immutable room identity on the
   next cold hydrate (comes back with roomId: null) and falls back to
   legacy name-keyed identity — breaking id-based rename/merge resolution
   and member-session titling ("Group: <roomId>").

Fix: durableGroupChatRooms now mirrors updateGroupChat's inline map
exactly — skip tombstones, carry roomId.

Tests: durableGroupChatRooms unit tests for both gaps, plus an
end-to-end reachability test (tombstone -> mergeRemoteGroupChatSnapshot-
IntoRooms -> persistGroupChatRooms -> storage) proving the merge really
does forward the tombstone and the fix really does keep it out of
storage. Mutation-verified against pre-fix code (all 3 new tests fail).
Full hermes-bots plugin test suite (60+ files) green.
2026-08-21 04:32:43 -07:00
Royalaid 76e0ca8826 fix(desktop): order-independent selection guard, layer-hint scoping, honest compositor receipt
Review-round residuals: the spinner's user-select guard now beats
[data-selectable-text] regardless of stylesheet order; the will-change
layer hint clears under the global renderer pause and reduced motion so
parked spinners hold no compositor layer; the e2e travel assertion reads
the engine's keyframes (a computed transform always serializes to a
matrix, so the old '%' check could never fail); inline import() type
hoisted for the lint gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:23:41 -07:00
Royalaid 0269504250 test(desktop): exercise the spinner CSS and the invalidation scope for real
Replaces the deleted stylesheet-text assertions with tests that run the
thing they claim to cover.

e2e/glyph-spinner.spec.ts drives a real browser, where the CSS actually
executes: the strip's animation resolves to steps(N) for N frames, runs
infinitely, and travels a resolved length rather than a percentage (a
percentage translate is layout-dependent and Chromium refuses to
composite it). Both pause gates are covered — the per-spinner
`data-paused` attribute and the global renderer-pause attribute that
window blur / minimize / document-hidden arm — along with the layer
promotion being scoped to running spinners. A sampling test confirms the
transform visits a bounded number of distinct values across one cycle
(steps, not a linear sweep) and that nothing mutates the DOM while it
animates, which is the property the whole change exists to deliver.

status-invalidation-scope.test.tsx pins the scoping itself as a render
count. `useTapbackDoubleClick` is called by AssistantMessageBody and by
nothing else in the tree, which makes it an exact render counter for the
message root without exporting internals. A settle and a delta flush must
both leave that count untouched while the leaves update. Verified by
mutation: reinstating a root-level status subscription fails the settle
test (2 renders where 1 is required).

It also pins node identity across the settle transition, so the
inter-agent collapse cannot go back to swapping element types at the
message-root position and remounting the row under the scroll anchor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwTc9XqUjhbay446VjugHZ
2026-08-21 04:23:41 -07:00
Royalaid 73dcd75afe perf(desktop): harden the spinner strip and tighten status invalidation
Review follow-ups on the compositor spinner and the invalidation scoping.

Spinner CSS:
- Clip each frame to its own box. Braille renders from a system fallback
  face (JetBrains Mono has no U+2800 block), whose metrics are not
  guaranteed to fit the 1em frame, so neighbouring ink could bleed into
  the viewport.
- Name descendants explicitly in the selection guard. The competing
  `[data-selectable-text='true'] *` rule has the same (0,1,0)
  specificity, so relying on inheritance made the winner depend on
  stylesheet order.
- Scope the compositor promotion to spinners that are actually running.
  A permanently promoted layer per parked spinner is pure memory at
  fan-out breadth, where many sit mounted and paused at once.
- Give every var() the braille default as its fallback, so a missing
  custom property degrades to a working spinner rather than an invalid
  declaration.

Spinner component: replace the bare `as CSSProperties` cast on the inline
style with an exported GlyphSpinnerVars contract, so a typo in a custom
property name is a compile error rather than a silently dead declaration.

Assistant message:
- Render the inter-agent collapse as a CHILD of the normal body instead
  of a competing root. The settled case previously returned a different
  element type than the running case, so settling unmounted the whole row
  and mounted a fresh one — discarding the DOM the scroll anchor held.
  One component, one root, children vary; the truth table is unchanged,
  including the collapsed row carrying no tapback listener.
- Collapse AssistantStatusSlot's separate subscriptions into one selector
  returning a stable string. The inputs always move together on a status
  flip, so reading them separately just multiplied the wake-ups.
- Give StreamingMarker a stable `data-slot` and assert on that rather
  than on `span.hidden`.

Repro script: count settled rows by subtracting streaming markers from
message roots instead of `:not(:has(...))`. The selector walked every
row's subtree on each evaluation, inside the very latency window the
probe measures.

Comments: drop the stale translateY(-100%) description, name both pause
triggers, replace hard-coded line-number citations with selector/symbol
ones, note that only the primary window arms the renderer-pause
attribute, and move the forensic trace numbers out of source comments
into the PR.

Delete the three tests that asserted on stylesheet TEXT. AGENTS.md bans
reading source in tests outright, and they demonstrated exactly why: a
var()-fallback edit that changed no rendered pixel broke one of them.
Replacements that exercise the CSS in a real browser follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwTc9XqUjhbay446VjugHZ
2026-08-21 04:23:41 -07:00
Royalaid 765e3a2f8a perf(desktop): composite-clean spinner strip, selection guard, swap-overlay ticker
Three items from the adversarial review of the compositor-only spinner.

1. COMPOSITOR PROMOTION. The keyframes travelled translateY(-100%), which
resolves against the strip's own box and so makes the animation
layout-dependent: instrumentation recorded a non-zero compositeFailed on
184/184 records (131072 / 131104) while a sibling transform animation using an
absolute length composited clean. The travel is now
calc(frames * -1 * frame-height), an absolute length for the same distance, and
the strip gets will-change: transform. steps(var(--glyph-spinner-frames)) and
the 1em frame metric are unchanged.

The frame height is now a custom property on .glyph-spinner, used by the clip
viewport, each frame box and the keyframe travel, so those three cannot drift.
On the em-resolution question: the keyframes apply to .glyph-spinner__strip and
nothing below .glyph-spinner declares a font-size, so the strip's em and the
frame's em are the same length -- the property makes that a single declaration
rather than a coincidence to re-verify.

2. SELECTION. .glyph-spinner takes user-select: none (plus -webkit-). These sit
inside [data-selectable-text] subtrees, where the strip contributed all N
glyphs to a transcript copy against the old implementation's one. None is right
for a decorative aria-hidden element.

3. SAME-CLASS SWEEP. chat-swap-overlay.tsx ran its own 80ms setInterval +
setState braille ticker -- the exact mechanism this fix removes. Its setFrame
drove only the glyph (setLabel is independent), and its frame set and cadence
are exactly the `braille` variant, so it now renders GlyphSpinner.
`justify-start` (tailwind-merge lets the caller win) keeps the glyph
left-aligned in its w-3 box as the bare span was. GlyphSpinner gains a `paused`
prop for it: the overlay stays mounted through its fade-out, and the old
cleared-interval behaviour was to stop animating once the swap was done.

Tests: guards for the two invisible-in-jsdom regressions -- keyframes must not
return to a percentage translate, and the selection guard must stay -- plus the
paused prop, and a new chat-swap-overlay.test.tsx pinning that no timer comes
back, the label still survives the fade-out, and the glyph freezes with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:23:41 -07:00
Royalaid b484933005 perf(desktop): compositor-only glyph spinner (no per-tick DOM mutation)
Scheduler attribution on the incident trace (FINDINGS.md round-5-Opus receipt)
puts 133 of 138 wide document-scale recalcs on GlyphSpinner's ticker, and 0 of
254 cheap ones. Replacing glyph.textContent every interval is a structural
text-node mutation, so each tick scheduled a style recalculation that resolved
against the whole document -- with N spinners mounted in a streaming
transcript, that is the incident.

Every frame is now in the DOM from mount as a vertical strip, scrolled by a
transform translateY keyframes animation. Transform animations run on the
compositor: no JS timer, no text mutation, no per-frame style recalc, layout or
schedule.

The strip is N frames tall and each frame is exactly 1em, so translating -100%
travels N frames; steps(N) (jump-end) samples that at 0, 1/N .. (N-1)/N, i.e.
it parks on frame 0..N-1 for one interval each and wraps -- the same sequence
and cadence the setInterval produced. Frame count and duration (N x interval)
arrive as inline custom properties, so all 18 spinner names / 16 distinct
frame-interval shapes share one keyframes rule with no generated or colliding
per-variant CSS.

Sizing, colour and alignment are unchanged: the outer cell keeps its exact
classes, and the 1em clipping viewport is centred by the same items-center that
used to centre the single glyph -- so consumer classNames that set a box
(size-3, size-3.5) or a font-size still land the way they did.

Gating semantics preserved, per the original "N mounted tabs each ticking burns
CPU for pixels nobody can see":
 - kept-alive hidden tab -> data-paused -> animation-play-state: paused. Kept
   explicit rather than relying on the pane's content-visibility:hidden, since
   that containment has a runtime kill switch and older pane layers only set
   visibility:hidden, which does not stop an animation.
 - window blur / minimize / document hidden -> the strip joins the existing
   :root[data-renderer-animations-paused] allowlist in styles.css, driven by
   main.tsx's installRendererAnimationPauseState(). That is the mechanism every
   other continuous decorative animation here already uses, so the per-spinner
   createRendererLoopPauseController goes away.
 - reduced motion is now honoured, which the ticker never did: the blanket
   @media (prefers-reduced-motion: reduce) rule freezes the animation. This
   also makes E2E screenshots deterministic, which that rule exists for.

The frames are marked aria-hidden. role="status" is a live region and the old
implementation rewrote its text ~12x/second, which announced a new glyph on
every tick.

Tests rewritten, deliberately: all five previous cases asserted the ticker
itself (vi.getTimerCount(), per-tick textContent), which no longer exists. The
replacements pin what jsdom can see -- frame order, the custom properties
feeding steps()/duration per variant, zero timers ever created, the hidden-tab
gate, aria-hidden, and that the strip is still named in the global pause rule.
The blur/minimize/document-hidden contracts now belong to that global mechanism
and are covered by lib/renderer-loop-pause.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:23:41 -07:00
Royalaid ced7d5e087 perf(desktop): move settled-preview derivation off the message root
The last status-dependent read at the message root, and the most expensive
one: the completedText selector flipped between '' while running and a full
messageContentText(content) join once settled, so every running <-> settled
transition re-ran the join for the whole message AND re-rendered the root. At
stream breadth N that is N joins plus N root re-renders per flip.

completedText and the previewTargets memo it feeds now live in a new
AssistantPreviewEmbeds leaf, mounted at the same position inside
[data-slot='aui_assistant-message-content']. Verified before moving that
previewTargets fed nothing else at the root -- its only consumer was its own
render block. The leaf renders the same wrapper div with the same classes, or
null when there are no targets, so the DOM is byte-identical; a component
boundary adds no node, so unlike StreamingMarker this needed no placement care
around the :first-child/:last-child rules.

The '' branch is preserved deliberately: it is the streaming-side optimization
that keeps the selector referentially stable so per-token flushes skip the
regex scan.

AssistantMessageBody now holds no status-dependent subscription at all -- what
remains is messageId, hasVisibleText, isInterim and turnDurationS, none of
which move on a pending flip.

Adds preview-embeds.test.tsx. The embed had no coverage, and the two cases are
written as a matched pair on the same selector -- present once settled, absent
while running -- so neither can pass vacuously.

Behavior-identical; invalidation scope only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:23:41 -07:00
Royalaid db6c282c91 perf(desktop): finish scoping streaming-status invalidation (data-streaming leaf, root isRunning)
Removes the two residual invalidators left by the previous commit.

data-streaming: gone from the message root. The flag is not dead -- it is the
settled-row signal for scripts/run-short-session-hang-repro.mjs -- so it moved
to a permanently-mounted, display:none leaf that is a ROOT-LEVEL sibling, and
the repro now matches on the descendant. Placement is load-bearing three ways:
a node inside [data-slot='aui_assistant-message-content'] would steal
:last-child from the stall indicator and change inter-bubble margins mid-stream
(styles.css:1995-2003); keeping it mounted and toggling only the attribute
keeps the per-flip write on a childless node instead of making it a DOM
structure change; display:none costs no layout or paint while querySelectorAll
and :has() still match it.

Renamed to data-message-streaming rather than reusing data-streaming: shiki
puts that exact attribute on deferred code cards, which are descendants of the
message root, so a descendant-matching selector sharing the name would report
any message holding a deferred code card as still streaming.

root isRunning: gone from the standard path. AssistantMessage now dispatches on
interAgentSender, so the collapse gate's live status subscription lives in
InterAgentAssistantMessage and only the rare inter-agent case pays it. The
enter animation captures its enabled flag once off the runtime, non-reactively,
because use-enter-animation.ts parks the value in a ref behind a useCallback([])
identity and consults it only when the callback ref fires at mount -- a live
subscription fed a value the hook already ignores.

Adds inter-agent-collapse.test.tsx: the collapse gate and the marker contract
both had zero coverage, and nothing in the app reads the marker, so a delete
would otherwise look free and silently regress the repro's response gate.

Behavior-identical; invalidation scope only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:23:41 -07:00
Royalaid 3f590e68df perf(desktop): scope streaming-status invalidation below message root
Prong A of the wide-recalc fix (FINDINGS.md round-4 protocol): read status in
leaf components inside message content, hoist MessagePrimitive.Parts so status
flips cannot re-render the parts subtree. Behavior-identical; invalidation
scope only.

The third primary edit -- dropping the data-streaming root attribute -- is NOT
in this commit. The design doc calls it dead based on a CSS grep, and that grep
is correct (every [data-streaming='true'] rule targets [data-slot='code-card']).
But it has a live non-CSS consumer: scripts/run-short-session-hang-repro.mjs
:928 and :1023 count settled assistant rows via
[data-slot="aui_assistant-message-root"]:not([data-streaming="true"]) and gate
the assistant-response wait on that count growing. Deleting the attribute makes
that selector match every row, so the gate would pass at stream start instead of
completion. Held pending a decision rather than silently weakening the repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 04:23:41 -07:00
Teknium d47252547b fix: lazy session-states import is best-effort under partial module mocks
Test harnesses that vi.mock('@/hermes') without setApiRequestProfile make
the session-states transitive graph unloadable; the deferred reconcile
import then rejected unhandled and failed unrelated suites in shard 2.
Catch and skip — the production graph always loads.
2026-08-21 04:08:43 -07:00
Teknium 3a2a12b752 fix: break session-states import cycle via lazy import in reconnectSecondary
Static gateway.ts -> session-states.ts import closed a module cycle that
left $activeGatewayProfile undefined at session-states init (TypeError:
Cannot read properties of undefined (reading 'get')) — the CI red across
all three UI shards. Dynamic import defers the edge past module init;
reconcile semantics unchanged. Proven by re-adding the static edge:
hud/pet suites reproduce the exact CI failure.
2026-08-21 04:08:43 -07:00
Teknium 4f64807f5d fix(desktop): stale running arcs clear on gateway reconnect (#53902, #73082)
A respawned backend re-mints runtime ids, so a pre-reconnect busy state
never receives its terminal busy:false publish and its session stayed in
$workingSessionIds forever - the sidebar running arc and agents-panel
'running' chrome lied for hours after the turn ended (the stale-flag
half of #53902/#73082; the CSS cost half landed in #91383).

reconcileBusyStatesOnReconnect() downgrades busy/awaitingResponse states
through publishSessionState (watchdogs disarm, stall hints drop, settle/
unread bookkeeping stays consistent), scoped by event-source: the primary
reconnect touches only scope-less runtimes, a secondary (registry)
reconnect touches only its own connection's. needsInput survives - a
blocking prompt is the user's to answer. A genuinely live turn re-asserts
busy on its next post-reconnect event, so the worst case is one arc blink.

Regression tests proven by sabotage run (neutered reconcile -> 5/6 fail).
2026-08-21 04:08:43 -07:00
Teknium 443d4387b5 perf(desktop): idle renderers stop burning CPU on infinite CSS animations (#53902, #73082)
The arc-border running indicator animated background-position (repaint
every frame: ~3,600 main-thread style recalcs/min per arc, ~4,100ms/min
of renderer task time measured over 60s of true idle) and progress-slide
animated left (forced layout every frame). Both now travel via transform
on the compositor: same visuals, 3,617 -> 79 style recalcs/min and
4,108 -> 153 ms/min task time in the same harness (-96%).
2026-08-21 03:04:57 -07:00
Teknium 0a8cdec697 fix(desktop): silence wsl.exe stderr banner + detached explorer relaunch rung
Follow-ups to the salvaged WSL-bridge gating (#66447):
- wsl-path-bridge.ts: discard wsl.exe stderr so the 'WSL is not installed'
  banner can never leak into an attached console on WSL-less machines (#80184).
- scripts/desktop-update/windows.ps1: add an explorer.exe-mediated detached
  relaunch rung between the WMI attempt and the tethered Start-Process
  fallback. When Win32_Process.Create fails (observed ReturnValue 8), the
  Desktop no longer re-attaches to the hand-off console, so its stdout stops
  flooding the window and the console can close.
2026-08-21 00:49:41 -07:00
Tranquil-Flow deec043276 fix(desktop): scope WSL bridge state by profile 2026-08-21 00:49:41 -07:00
Tranquil-Flow b634032fa4 fix(desktop): gate WSL bridge for remote backends
Seed backend-mode state before creating the first window and update it after runtime resolution. Keep remote reconnects gated until a local backend is confirmed.

Cover Windows child-process suppression and bridge state transitions.
2026-08-21 00:49:41 -07:00
Brooklyn Nicholson 524b062289 fix(desktop): don't re-list a just-unpinned session under Pinned
The Pinned section falls back to the server `pinned` flag for rows the
local set doesn't hold, so a backend pin stays reachable when
localStorage is cold (#85969). But an unpin leaves the local set the
instant the user clicks, while the loaded row keeps reporting
`pinned: true` until a page issued after the PATCH lands — so the
fallback read the user's own unpin as a foreign pin, parked the session
at the bottom of Pinned, and only released it a refresh cycle later.

session-pin-sync already knows which rows its own in-flight writes
contradict; publish that fence and have the fallback skip them.
2026-08-21 01:19:15 -05:00
brooklyn! 02e270a47e fix: editing a message in an old session fails (profile DB + window-relative ordinal) (#91302)
* fix(gateway): persist prompt.submit truncation to the session's own profile DB

`_get_db()` returns the LAUNCH profile's SessionDB handle. App-global
remote mode gives a session its own profile (`session["profile_home"]`)
whose transcript lives in that profile's `state.db`, so a write keyed on
`session_key` that goes through `_get_db()` addresses the wrong database.

In the `prompt.submit` truncate branch that has two consequences. The
edit/resend never sticks — `session.resume` reopens the profile db and
resurrects the undone turns — and when the launch profile happens to hold
a row under the same session id, the truncated transcript is inserted
into a profile the session does not belong to.

It also silently voids the branch's own fail-closed contract. The handler
persists before it rewrites `session["history"]` precisely so that a
failed write refuses the turn and leaves memory and DB aligned; that only
holds if the handle it checks is the one that owns the row.

`_session_db(session)` is the profile-aware resolver that already exists
for this: the profile's `state.db` when `profile_home` is set, otherwise
the shared launch handle. Non-profile sessions are unaffected —
`_session_db` borrows the same shared handle and leaves it open.

`active_only=True` and `archive_dropped=True` are carried through
unchanged; only the handle the call is made against changes.

* fix(gateway): resolve the /undo command against the session's own profile DB

`command.dispatch`'s `/undo` branch opened the launch profile's handle via
`_get_db()`, but every read and write under it is scoped by session id:
`list_recent_user_messages`, `rewind_to_message` and the
`get_messages_as_conversation` reload all key on `session_key`.

For a session with its own profile (`session["profile_home"]`) the rows
live in that profile's `state.db`, so against the launch handle
`list_recent_user_messages` returns nothing and the command fails closed
with `4018 "no user messages to undo"` — for the entire session, on every
invocation, even though the transcript is right there in the profile db.

Route the whole branch through `_session_db(session)`, which yields the db
that owns the session's row and closes a profile handle on exit. Sessions
without a profile keep borrowing the shared launch handle exactly as
before, so this is behaviourally identical for them.

* fix(gateway): read /history and /context from the session's own profile DB

`_format_live_history_output` and `_format_live_context_output` rebuild the
transcript from the database rather than from `session["history"]`, because
the in-memory list is empty for a session this process did not run itself.
Both reads are scoped by session id but were issued against `_get_db()`,
the launch profile's handle.

A session with its own profile (`session["profile_home"]`) keeps its rows
in that profile's `state.db`, so both reads come back empty and the
commands under-report: `/history` renders "No conversation history yet."
and `/context` falls back to the empty in-memory list and reports a
conversation of zero messages. Both swallow their exceptions, so there is
no error either — just a wrong answer about the user's own transcript.

Resolve both through `_session_db(session)`, the profile-aware resolver
used by the rest of the session-scoped paths.

* test(gateway): cover session-scoped transcript ops against a profile DB

Regression coverage for the three session-scoped sites that resolved
against the launch profile's handle instead of the db owning the
session's row. Each test drives the real JSON-RPC entry point with a
session carrying `profile_home`, seeds the transcript into the profile's
own `state.db`, and asserts against both databases.

Per site, with the production change reverted to its pre-fix form:

- `prompt.submit` truncation — `test_truncation_persists_to_the_profile_db`
  and `test_truncation_does_not_copy_rows_into_the_launch_profile` fail.
  The second seeds a row under the same session id in the launch db so the
  foreign write succeeds instead of failing a key check, which is the case
  that copies a transcript into a profile it does not belong to.
- `/undo` — `test_undo_rewinds_the_profile_transcript` fails with
  `4018 "no user messages to undo"`.
- `/history` and `/context` — `test_history_reads_the_profile_transcript`
  and `test_context_reads_the_profile_transcript` fail, reporting an empty
  conversation.

`test_undo_still_uses_the_shared_handle_without_a_profile` and
`test_truncation_without_a_profile_uses_the_shared_handle` pin the
unchanged path: with no `profile_home` the resolver must borrow the shared
launch handle and leave it open. Both stay green in every direction, so a
future change cannot satisfy the profile cases by abandoning the shared
one.

* fix(desktop): aim truncations by durable id alone on tail-only transcripts

The cold-open transcript is a newest-first prefetch page
(LATEST_SESSION_MESSAGES_LIMIT = 120) with the resume RPC sent
omit_messages — older rows only arrive via "Show earlier" backfill.
planEdit/planReload/planRestore still counted truncate ordinals over
that windowed list, so every edit/reload/restore in a session longer
than the prefetch page sent a window-relative ordinal alongside the
durable row/message id. The gateway's #82959 cross-check resolved the
durable id to its full-history ordinal, read the offset as drift, and
refused with 4030 — making the Edit affordance permanently dead in
long sessions.

When the transcript may be tail-only (the transcript-tail
bookkeeping's possiblyTruncated), drop the client ordinal and address
the truncation by durable id alone — the same rule runRewindSubmit
already applies to content-resolved row ids (#87059). The ordinal
tripwire stays on whenever the transcript is complete.

Closes #88082

* fix(desktop): drop client rewind ordinal whenever a durable id is present

#88092 gated the drop on tail-only prefetch. After in-place compact the
live scrollback is treated as complete, so Restore still sent a
display-lineage ordinal next to a resolved row id and the gateway
refused with 4030 (#89244). prefix_user_count is structurally 0 on
in-place because get_ancestor_display_prefix is cross-session.

Same choke point: if a durable truncate_before_row_id or a real
truncate_before_message_id is present, omit the client ordinal.
confirm_empty_truncate is still carried from a caller ordinal of 0.
Unknown ids still fail closed at 4018.

Closes #89244

---------

Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: zengzheqing <yuntianqing@yahoo.com>
2026-08-21 06:02:19 +00:00
brooklyn! 6e64e6b9c6 fix(desktop): Windows glass windows stop rendering when they lose focus (#91307)
* fix(desktop): stop layering Windows glass windows, and don't make them transparent

A Windows chat window under glass rendered while focused and went dead the
moment it lost it. Two things put it on a compositing path DWM will not draw
acrylic behind, both no-ops that looked free:

`opacity: windowOpacity()` was passed on every window. Under glass on Windows
fade is 0, so the value is always 1 — but Electron's `SetOpacity` calls
`SetLayered()` and `SetLayeredWindowAttributes(..., LWA_ALPHA)` before it looks
at the value, and nothing ever takes `WS_EX_LAYERED` back off. A layered window
composites through the legacy redirection surface, which Windows documents as
mutually exclusive with `UpdateLayeredWindow`. Opacity is now only passed when
the state actually fades, and the runtime path keeps setting it for a window
that is already faded so it can still come back to opaque.

`transparent: true` was set on every glass-capable Windows chat window, on the
premise that DWM materials only reach the client area that way (electron#49443,
which was closed as need-info against an EOL Electron 28). They do not need it:
`IsTranslucent` answers yes off `background_material_` alone, which is what
gives the page its transparent default backing, and `SetBackgroundMaterial`
flips widget translucency live. Its one gate is a frameless window, and
`titleBarStyle: 'hidden'` already satisfies it. What `transparent` did add was
permanent — the widget pinned to kTranslucent for the window's whole life, so
even glass-OFF windows paid a DirectComposition redraw per frame
(electron#39895), plus the documented transparent-window limits, including that
a resizable transparent window is unsupported and breaks (electron#48421).

Both landed latent in #89837 and only surfaced when #90587 turned glass on by
default and dropped the opaque backing that had been hiding them.

* docs(desktop): name the one thing the opacity guard cannot undo

Electron exposes no way back off WS_EX_LAYERED, so a Windows window that has
been faded once keeps the layered compositing path until it is recreated. Not
opening the door on the default path is the whole of the fix; say so where the
guard lives rather than leaving a reviewer to work out the gap.
2026-08-21 05:57:14 +00:00
Teknium 7af4b8ee92 test(bot-mode): drop unused stub param flagged by eslint 2026-08-20 21:54:18 -07:00
Teknium c6745da72b feat(bot-mode): durable room identity + full gateway fan-out for group-room sync
Resolves the room-lifecycle class on top of the salvaged #89369 projection:

- v3 projection keys rooms by immutable roomId (id:<roomId>) with
  name:<name> fallback for legacy rooms; v1/v2 envelopes are normalized
  on read so mixed-version fleets share one merge path
- rename is now a same-key field update — no distributed delete+create,
  no old-name resurrection from lagging gateways
- id tombstones are FINAL (ids are never reused), so a gateway that was
  offline during a disband can never resurrect the room, regardless of
  the revision its stale copy carries; same-name recreation is unaffected
  because it mints a fresh roomId
- the projection fans out to EVERY reachable default-profile gateway
  (per-gateway job queues, CAS revision streams, backoff and retry caps),
  so rooms survive any single gateway dying and surface on gateway-only
  clients without waiting for a Desktop to foreground that gateway
- cold hydrate follows a remote rename via roomId instead of duplicating
  the room under both names

New tests: id-keyed rename continuity, final id-tombstones vs lagging
high-revision copies, rename-job shape (changed+deleted same key),
cold-hydrate re-keying, multi-gateway fan-out. Sabotage-verified: each
new test fails against the pre-class behavior.
2026-08-20 21:54:18 -07:00
Teknium 5d75a46645 fix(bot-mode): stabilize sync entry identity across gateway round-trips
Two follow-ups to the salvaged #89369 base against current main:
- compact projection entries no longer overwrite the local rich copy
  (attachments survive; watermark accounting stays stable)
- synthetic legacy-N thread ids collapse to one bucket in the entry key,
  so id-less entries don't duplicate after a pull and manufacture
  phantom member turns into busy sessions
2026-08-20 21:54:18 -07:00
TheTom 3b83b5e1fa fix(gateway): serialize shared bot room updates 2026-08-20 21:54:18 -07:00
TheTom 7656a2ad46 fix(desktop): hydrate shared bot room previews 2026-08-20 21:54:18 -07:00
TheTom 64d393cb7e fix(desktop): sync bot room previews through profiles 2026-08-20 21:54:18 -07:00
hermes-seaeye[bot] 949aff69a8 fmt(js): npm run fix on merge (#91256)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-21 02:51:33 +00:00
Jaaneek 95057c2a63 feat(desktop,web): enable the React Compiler in both renderer builds
Wire babel-plugin-react-compiler through @vitejs/plugin-react v6's
reactCompilerPreset + @rolldown/plugin-babel in the web and desktop
vite configs, scoped to modules that can actually contain components
or hooks (JSX syntax or a react-ish import — the preset's default
filter babel-parsed every TS module). Both vitest configs run compiled
components, so rules-of-react violations fail in CI.

Also fixes the latent bug the compiler exposed: usePluginI18n kept a
stable translator identity over a mutating locale registry, so
memoized consumers (React.memo today, compiled components tomorrow)
served stale strings after a late bundle registration. The registry
version now keys the translator identity — correct with or without
the compiler.
2026-08-20 19:44:26 -07:00
zhongwater123 ed2d01a3c3 fix(desktop): dedupe profile mention completions 2026-08-20 19:42:09 -07:00
Mengfan ba501f02ce fix(desktop): Bot Mode mention readers miss the connection-suffixed roster cache
useRoster() keys its query on [...ROSTER_KEY, connectionId] so every
connection the window has been on gets its own cache entry, but the two
imperative roster readers — the @mention completions and the mention
middleware — still called getQueryData(ROSTER_KEY). TanStack Query's
getQueryData is an exact-key match, so the bare key matched nothing and
both readers saw an empty roster: the composer offered no agent handles,
and remote @name-device mentions (e.g. @default-vera for a same-named
profile on an SSH connection) fell through to the local profiles.list
fallback, which only knows bare local names — the mention passed through
untouched to the local agent instead of being routed over Connections.

Add a cachedRoster() helper: honor the legacy bare-key write first, then
scan the key family with getQueriesData, preferring the active
connection's entry and falling back to any other entry (a stale roster
beats none). Never throws, matching the readers' existing posture.

Regression tests model the real cache shape — entries under the suffixed
key, exact-key reads missing them — so the key-shape mismatch is pinned;
the previous tests stubbed getQueryData to ignore keys, which is exactly
why this slipped through.
2026-08-20 19:42:00 -07:00
Gille 76952ba54f fix(desktop): follow focused bot for mentions 2026-08-20 19:12:39 -07:00
ArthurFranckPat 770f9a5747 fix(desktop): read the live union roster in mention-completions + mention middleware
useRoster caches under [...ROSTER_KEY, activeConnectionId] — a 3-element
key — but the composer @ autocomplete provider and the mention-routing
middleware both read getQueryData(ROSTER_KEY) with the bare 2-element
key. An exact-match lookup against a 3-element cache key returns
undefined forever, so:

- @ autocomplete offered ZERO bot rows (local and remote alike)
- remote-bot mentions never matched the union roster and fell back to a
  local-only profiles.list, so @name-device mentions did not route

That is issue #89303: the remote-source Bot row tells the user to
`@name-device` the agent, and the completion surface never offers it.

Fix: cachedUnionRoster() — read the live connection's cache entry first
(getQueryData with the full 3-element key), fall back to a prefix scan
(getQueriesData) keeping the freshest snapshot. Never throws; callers
keep their existing cold-cache fallbacks.

The plugin test mocked getQueryData as key-agnostic, which is why this
never failed in CI: the mock now reproduces the real QueryClient v5
exact-key semantics (a bare ROSTER_KEY lookup misses the 3-element
cache), and the tests fail against the old code (verified: 2 failures
before the plugin fix, 9/9 pass after).

Fixes #89303
2026-08-20 19:12:39 -07:00
Teknium e30388e409 fix: retained group panes re-anchor to the latest message on reopen
Keep-alive workspace panes (#89788) stay mounted while hidden, so returning
to an already-open room never remounted GroupChatWorkspace and the
mount-time bottom anchor didn't rerun — reopening group A after visiting
group B left A at the old scroll position. GroupChatMainView now subscribes
to the pane's visibility via feature-detected host.paneVisibility (always-
visible atom fallback for older SDKs) and the workspace scrolls the bottom
sentinel into view on the hidden→visible edge. Repro and fix direction from
the live-audit review comment on #90526.

Also updates the composer shape test to slice only GroupMentionInput
(GroupClarifyCard, merged since the branch was cut, legitimately uses
Input) and teaches the legacy-SDK react proxy about useMemo.
2026-08-20 14:25:50 -07:00
Teknium 3c5fb284e5 fix(bot-mode): group-room UX cluster — Shift+Enter newlines, open-at-latest, late replies harvested (#89884, #89835, #89545)
Composer was a single-line Input (Enter always submitted, newlines
impossible) — now the SDK Textarea with Enter=send / Shift+Enter=newline
and popover-first key handling. Room log had no scroll anchoring (opened
at position 0) — bottom sentinel + near-bottom-guarded anchor effect.
Stranded replies were only harvested inside an active turn loop (stuck
until the user's next send) — the settle path now runs a bounded
background harvest that yields to a live loop.
2026-08-20 14:25:50 -07:00
Bikash Joshi 693d6341b7 fix(desktop): expose group deletion from roster rows 2026-08-20 14:14:51 -07:00