Commit Graph

24090 Commits

Author SHA1 Message Date
Teknium db4b840b72 ci: retrigger — zero-job dispatch on 5a3b1c2de 2026-08-19 23:28:42 -07:00
Teknium 5a3b1c2de8 chore: retrigger CI (zero-job dispatch failure, auto-heal) 2026-08-19 23:22:04 -07:00
Teknium 01c3bd4c81 test: explicit keyless Firecrawl selection asserts the keyless cloud route
The salvaged #50659 behavior makes 'firecrawl selected, no creds' a
WORKING keyless state, so the old expectation (hard error naming
FIRECRAWL_API_KEY) is stale. The test now mocks httpx and asserts the
request routes to api.firecrawl.dev with results returned — still
proving keyless Tavily can't silently take over, which was the test's
point. Also stops the test making a real network call in CI.
2026-08-19 23:20:52 -07:00
Teknium 797bc4bf9b Merge remote-tracking branch 'origin/main' into feat/keyless-tavily-firecrawl-failover 2026-08-19 23:04:36 -07:00
Teknium c492379497 chore: map contributor email for Tavily salvage 2026-08-19 23:04:20 -07:00
Slobaka 6ff341c4d6 fix(doctor): web readiness reflects the selected provider's real state (#78412)
Salvaged from #78434 by @Slobaka (also the issue reporter; earlier than
the competing #78436). hermes doctor no longer paints a green web check
when the explicitly selected provider cannot initialize — web splits
into per-capability rows (web search / web extract) resolved through
the same registry resolvers the dispatchers use, with readiness from a
true availability probe (_provider_is_ready).

Keyless-tier integration on top of the salvage:
- _provider_is_ready counts is_keyless_available() as ready — keyless
  mode is a working state, not a misconfiguration (zero-config installs
  and selected-keyless Tavily/Firecrawl show ok, not warn)
- Tavily/Firecrawl gain is_keyless_available() (True only when
  explicitly selected — they stay out of the zero-config fallback)
- doctor triggers plugin discovery before reading the registry (fresh
  doctor processes saw an empty registry and warned on everything)

E2E: searxng-selected-without-URL warns (the #78412 repro);
zero-config, tavily-keyless, firecrawl-keyless all read ok;
parallel pinned paid without a key warns.
2026-08-19 23:03:58 -07:00
Brooklyn Nicholson f983828077 fix(desktop): stamp the focused profile on projects RPCs and refresh a remote scan
Remote mode used to return before asking the host for repos, and never sent
profile, so the sidebar stayed on the launch list. Ask discover_repos to scan,
forward the focused profile on every projects call, and drop late responses
from a profile the user already left.

Co-authored-by: Chen Jin <Enough1122@users.noreply.github.com>
Co-authored-by: Ryan Weddle <weddle@gmail.com>
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: webtoolbox <1911826+webtoolbox@users.noreply.github.com>
2026-08-20 01:02:17 -05:00
Brooklyn Nicholson 4dcefed089 fix(tui_gateway): scan remote git roots and scope projects.* to the focused profile
A remote desktop cannot crawl the host disk, and projects.* always read the
launch profile's stores, so switching profiles left the wrong tree on screen.
Bind the requested profile's HERMES_HOME and session db for the whole family,
and add scan:true so repos with no Hermes sessions still appear.

Co-authored-by: Chen Jin <Enough1122@users.noreply.github.com>
Co-authored-by: Ryan Weddle <weddle@gmail.com>
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: webtoolbox <1911826+webtoolbox@users.noreply.github.com>
2026-08-20 01:02:17 -05:00
Teknium 481bc9391e fix(memory): profile-only config gets narrow USER_PROFILE_GUIDANCE instead of the full memory block
With memory_enabled: false but user_profile_enabled: true, the memory tool
stays (it backs USER.md) but the full MEMORY_GUIDANCE told the model to save
notes to a MEMORY.md store that does not exist. Split the guidance: a
profile-only block is injected for that configuration, directing writes to
target='user' only.
2026-08-19 22:59:17 -07:00
HexLab98 a969c5a93d test(memory): cover the disabled built-in memory surface
Walks the real resolution chain -- config.yaml on a temp HERMES_HOME ->
check_memory_requirements -> get_tool_definitions -- rather than mocking
the availability check, since the bug was in how the flags reach the
schema. Covers both flags off, either one alone, no config file at all,
and a config read that raises (must fail open).

Also asserts the external provider's tools survive with the built-in tool
gone, so the fix cannot regress into taking Hindsight/Mem0 down with it,
while disabled_toolsets keeps its documented "hide everything" meaning.

The existing MEMORY_GUIDANCE test built a skip_memory agent whose flags
were both false, so it was asserting the old tool-presence-only behavior;
it now states its precondition and gains the false-case mirror.
2026-08-19 22:59:17 -07:00
HexLab98 d5cddae187 fix(memory): drop dead memory tool and guidance when built-in stores are off
With memory.memory_enabled and memory.user_profile_enabled both false,
agent_init never builds a MemoryStore -- but check_memory_requirements()
returned True unconditionally and MEMORY_GUIDANCE was gated only on the
tool being present in valid_tool_names. So the tool shipped in every
request's schema while answering "Memory is not available" on every call,
and the system prompt still told the model to save durable facts there.

Gate both on the config flags, using the store predicate for the tool and
the already-resolved agent state for the guidance (config is not re-read
mid-conversation, so the prompt stays byte-stable). Either flag alone
still backs the tool, so only turning both off removes it.

This lets a user running a third-party provider (Hindsight, Mem0, ...)
turn the built-in files off without paying for the dead surface on every
API call. The provider's own tools are unaffected: hiding the built-in
tool moves the decision onto the toolset gate, and listing memory under
agent.disabled_toolsets remains the only switch that takes those down.
2026-08-19 22:59:17 -07:00
kshitij 37fa4a7c63 chore: remove unused import pytest from test file
Follow-up cleanup from simplify-code review on PR #90521 salvage.
2026-08-20 11:28:26 +05:30
liuhao1024 fbca706789 fix(telegram): log the first confirmed getUpdates progress per generation
Both polling reconnect paths end on the same 'health pending getUpdates
progress' line, and _record_polling_progress completed silently — so the
log stream for 'reconnected and healthy' was byte-identical to
'reconnected and hung', and a wedged long-poll (#87057 / #69314 /
#71239 class) stayed invisible until a user noticed silence. The only
detection method was sending the bot a test message (#90504).

Emit one INFO on the first confirmed getUpdates round-trip of each
generation, inside the existing event-set branch so steady-state polling
adds no log volume. This turns the pending line into a resolvable pair
('health pending' -> 'confirmed healthy') whose absence after a
reconnect is a reliable hung-poll signature.

Fixes #90504
2026-08-20 11:28:26 +05:30
Teknium 2eb5217af9 feat: keyless free-tier failover + Tavily/Firecrawl salvage integration
- Cross-vendor failover: when Exa's or Parallel's keyless free tier
  returns a rate-limit-shaped error, the request retries once on the
  other vendor's free endpoint (search + whole-batch extract). Result
  notes served_by; a peer pinned to its paid tier is never used;
  non-throttle errors never fail over.
- Docs: failover note + Tavily/Firecrawl keyless-when-selected rows.
- Firecrawl keyless test expectations aligned with the keyless tier.
2026-08-19 22:58:05 -07:00
Gille 188d47919a feat(computer-use): expose screenshots for chat delivery 2026-08-19 22:58:02 -07:00
Teknium 2d59cb4386 fix(api-server): 'max' and 'ultra' reasoning efforts are no longer silently ignored on API/browser requests
_request_reasoning_config() whitelisted none..xhigh, so a client sending
max or ultra (valid /reasoning + config.yaml levels) fell through to the
default effort with no error. The server now accepts the full internal
ladder (hermes_constants.VALID_REASONING_EFFORTS); per-provider wire
clamping happens downstream via agent.reasoning_effort, same as every
other entry surface. Salvages the api_server hunk of #78216 (credit
@snowzlmbot); the un-clamping half of that PR was rejected separately.
2026-08-19 22:56:14 -07:00
Teknium 4999af5cc1 fix: K3 plan-variant slugs (k3-256k) now get K3's effort vocabulary
kimi_supported_efforts() used exact/prefix matching and missed Kimi
Coding plan variants like k3-256k, which fell back to the K2-era
low/medium/high set and mistranslated efforts on a K3 wire. Replaced
with the boundary-token regex from #76427 (credit @ruizanthony), which
matches k3/k3-256k/kimi-k3* without matching kimi-k2.6 or mk3000.
2026-08-19 22:56:04 -07:00
LeonSGP43 f51e61136a fix(web): explicit Firecrawl selection works keyless against the public cloud API
Salvaged from #50659 by @LeonSGP43 onto current main (the client
resolver was rewritten for strict-selection semantics since the PR;
reapplied the keyless mode as a third client_mode inside the new
resolver). An explicit firecrawl selection with no FIRECRAWL_API_KEY /
FIRECRAWL_API_URL now routes through a minimal REST client (v2 search +
scrape, no Authorization header) instead of erroring. Unconfigured
installs never route here — the keyless path requires the explicit
selection. Fixes #49912.
2026-08-19 22:54:28 -07:00
hermes-seaeye[bot] bbb7b607b4 fmt(js): npm run fix on merge (#90552)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 05:49:39 +00:00
Lakshya Agarwal 6bf4375755 feat(onboarding): enhance Tavily backend support for keyless access
- Added support for keyless Tavily integration in the onboarding flow, allowing it to be recognized as available without an API key.
2026-08-19 22:44:11 -07:00
Lakshya Agarwal ee37f3d897 feat(tavily): update Tavily integration to support keyless access
- Updated the Tavily API key description to clarify that it is optional and keyless access is supported.
- Modified the Tavily plugin and provider to handle requests with or without an API key, using Bearer authentication when the key is provided.
- Enhanced documentation to reflect the new keyless functionality and updated environment variable descriptions.
- Added tests to ensure correct behavior for both keyed and keyless requests.
2026-08-19 22:42:58 -07:00
Brooklyn Nicholson bf4f5e17f8 feat(desktop): show unread count on the sessions sidebar toggle
A small overlay on the left sidebar icon (right when panes are flipped) so a
closed sessions list still reports unfinished-unread chats.
2026-08-20 00:42:21 -05:00
Brooklyn Nicholson 508b3cf65d feat(desktop): count unread sessions from the shared status map
The titlebar badge needs the same unread answer the green dots use, without
double-counting lineage aliases that are not listed rows.
2026-08-20 00:42:21 -05:00
Teknium fa89d87ea6 fix(checkpoints): bare /rollback falls back to a labeled all-directories view (#10505, reapply #10633) 2026-08-19 22:41:13 -07:00
Teknium 7bf66ec39b fix(compression): /compress refusal no longer reports a successful rewrite 2026-08-19 22:41:05 -07:00
Brooklyn Nicholson cce0427905 fix(desktop): authenticate gated file downloads like REST
saveGatewayFile rode the OAuth cookie partition even when hermes:api already
held a native bearer, so listing worked and Download 401'd.

Co-authored-by: 686f6c61 <github@00b.tech>
2026-08-20 00:39:56 -05:00
Brooklyn Nicholson b7b6ee0118 fix(desktop): name the gated file-download auth decision
Downloads have to present the same bearer-vs-cookie choice as oauth REST.
A cookie-only save against a cookieless native session is the Files-panel 401.

Co-authored-by: 686f6c61 <github@00b.tech>
2026-08-20 00:39:56 -05:00
Teknium d0573880a8 fix: Codex Responses effort vocabulary is now per-model — gpt-5.5 no longer 400s on 'max' (#68365 confirmed live)
Live probes against api.openai.com/v1/responses (Aug 2026):
- gpt-5.6: accepts none/low/medium/high/xhigh/max; rejects minimal, ultra
- gpt-5.5: accepts none/low/medium/high/xhigh; rejects max ('Unsupported
  value'), minimal, ultra

So #68365's premise was half right: 'max' does 400 — but only on pre-5.6
models; blanket-clamping max->xhigh on gpt-5.6 (its fix) would have capped
the one model that supports max. The declared-vocabulary design absorbs
this as data: codex_supported_efforts(model) picks CODEX_GPT56_EFFORTS or
CODEX_LEGACY_EFFORTS, and the shared clamp does the rest. Both the main
Codex transport and the auxiliary client's Responses path use it.

Wire outcomes: ultra -> max on gpt-5.6, ultra/max -> xhigh on gpt-5.5/o5,
minimal -> low everywhere.
2026-08-19 22:37:46 -07:00
Brooklyn Nicholson ddb305c602 fix(desktop): give mermaid diagrams a pixel size in the overlay and on copy
Mermaid emits width="100%". Inside the zoom viewer's shrink-to-fit grid
that percentage can collapse, and svgSize's parseFloat("100%") made a
100px PNG so copy fell back to raw SVG text.

Co-authored-by: Robert Mohid <rmohid@gmail.com>
2026-08-20 00:31:43 -05:00
Brooklyn Nicholson 6a3a1f411b fix(desktop): mermaid zoom overlay body collapsed to zero height
The Dialog shell is a fixed-height flex column, but the body had no
flex-1. The toolbar is absolutely positioned, so the in-flow stage had
nothing to resolve against and clipped the SVG.

Co-authored-by: Anuvrat Rastogi <anuvrat.rastogi@sap.com>
2026-08-20 00:31:43 -05:00
victor-kyriazakos 3683e70043 feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half) (#85796)
* feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half)

NS-658. Three additive ops within contract v1, emitted only when the
connector's negotiated descriptor advertises them:

  {op: draft, chat_id, draft_id, content, final, metadata}
  {op: task_card, chat_id, card_id, chunks, metadata}
  {op: task_card_stop, chat_id, card_id, metadata}

The gateway side is deliberately dumb: no platform API knowledge, no new
config keys. Slack mechanics (chat.startStream/appendStream/stopStream,
per-workspace feature-gate cache, send+edit fallback) live connector-side
where the platform adapter lives in the relay model.

Semantic bridge: base send_draft is Telegram-shaped (draft clears; final
is a separate send). Slack native streaming makes the stream THE message.
The adapter tracks the open draft per chat and converts the turn-final
send() into draft(final=true) so the connector seals the stream instead
of posting a duplicate; the stream ts returns as the message identity.
A failed frame disarms interception so the edit-based fallback's real
send goes through untouched.

BEHAVIOR CHANGE (deliberate): relay supports_draft_streaming() now
requires the descriptor flag AND the draft op. Flag-only was a latent
lie — send_draft inherited NotImplementedError, so a connector setting
the flag without the op would have crashed the stream consumer's draft
path. supported_ops stays fail-open for legacy (pre-contract) ops;
draft/task_card did not exist pre-contract and must not fail open.

Task cards ride #85476's adapter-agnostic TurnRunner seam (hasattr on
send_native_task_card_progress); supports_native_task_cards() is the
descriptor probe. Connector half + E2E harness pair follow in the gg
repo.

* fix(relay): expose native_task_cards_enabled() on the relay adapter

Live-canary finding (Alice, staging): the TurnRunner's task-card lane
probes adapter.native_task_cards_enabled() (the native Slack adapter's
opt-in contract). The relay adapter only offered
supports_native_task_cards(), so the hasattr gate failed silently and
tool progress stayed on the text path — draft streaming worked, cards
never rendered. Alias it to the descriptor probe.

* fix(relay): match task-card methods to the TurnRunner's native keyword contract

Live-canary finding #2 (Alice, staging): gateway/run.py's card lane calls
send/stop_native_task_card_progress with the NATIVE Slack adapter's
signature (tasks/title/reply_to/metadata/fallback_text, keyword-only) —
PR 85796's relay methods took a positional card_id, so every call raised
TypeError('unexpected keyword argument reply_to') in the progress task,
repeatedly killing the card publisher (and the retry loop resent the
final delivery 4-5x). Card id now derives per turn thread
(turn:<reply_to>), thread_ts anchored like draft; title/fallback_text
accepted for parity, not forwarded (plan-mode stream renders chunks).

* fix(relay): one draft stream per turn for stream-is-the-message adapters

Live-canary finding #4 (Alice, staging): the stream consumer bumps
draft_id at every tool boundary so Telegram-shaped drafts animate each
text segment as a fresh preview. On relay Slack NATIVE streaming a new
draft_id opens a brand-new chat.startStream — the user saw one frozen
message per segment (stuck streaming cursor ▉, never sealed: only the
LAST stream gets the final=true seal) plus the real final; 5-6 cumulative
snapshots per turn. Adapters that mark draft_stream_is_message keep ONE
stream per turn: tool progress lives in the native task card, and the
connector's suffix-delta falls back to whole-text append on prefix
mismatch, so segments append cleanly. Telegram-shaped drafts keep the
per-segment bump.

* fix(relay): don't seal the native stream at tool boundaries — only the turn-final does

Live-canary finding #5 (Alice; supersedes the incomplete #4 which was
necessary but not sufficient). Root cause CONFIRMED by integration trace
(test_live_cards_flow_trace.py, real consumer semantics + real adapter +
stub transport): at every tool boundary the consumer calls
_send_or_edit(finalize=True), which skips the draft path and issues a
real send(); the relay adapter's seal-interception converts THAT into
draft(final=true) — sealing the stream once per segment. Timeline showed
3 seals for a 3-segment turn: exactly the frozen cumulative ▉ snapshots
seen live (the replaced stream never gets stopStream, keeping its cursor).

Fix: for draft_stream_is_message adapters, a segment-break finalize
(finalize=True, is_turn_final=False) stays ON the draft path as another
cumulative frame; only got_done (is_turn_final=True) falls through to
send() and seals. Telegram-shaped platforms unchanged. Trace test now
pins the invariant: ONE user-visible message per turn.

* fix(relay): strip the text cursor from native draft frames

Live-canary finding #6 (Alice) — the ACTUAL duplicate-content mechanism,
confirmed by full-flow scan of both sides' code + logs. The consumer
appends its text cursor (▉) to every non-final display_text tick. The
connector's stream sender diffs CUMULATIVE frames via prefix check:
'abc▉'.startsWith → 'abc def▉' is NEVER a prefix match (the cursor sits
mid-string), so deltaFor falls back to whole-text append on EVERY tick —
chat.appendStream stacks each full cumulative snapshot (cursor included)
into the ONE stream message. Exactly the observed thread: repeated
blocks, each ending in a frozen ▉, growing per tick.

Fixes #4/#5 were real (one stream per turn now) but this was the last
mechanism standing. Native streams render their own typing indicator, so
the text cursor is pure noise on this path: strip it from draft frames.
Prefix check now holds; every tick appends only its true suffix delta.

* fix(relay): seal-interception covers EVERY egress door, not just send()

Live-canary finding #7 (Alice): one duplication remained after #6 — the
stream froze mid-word with the live indicator (never sealed) and the
final posted as a separate message. Log receipt: 'Queued follow-up:
final text delivery confirmed; delivering explicit media before
continuing' — the turn's final went out via the DELIVERY RESOLVER lane
(gateway/delivery.py), which calls send_for_platform() DIRECTLY,
bypassing send() and its seal-interception. The open stream never
absorbed the final; it arrived as a plain 'send' op → chat.postMessage.

Fix: hoist the open-draft check to the top of send() (ahead of the
explicit-platform branch) AND add it to send_for_platform() — an open
native stream absorbs the turn-final regardless of which egress door it
arrives through. The stream IS the message.

* fix(relay): failed seal falls back to plain send (PR 85796 AI-review point 1)

A turn-final seal that fails at the transport must never swallow the
final answer: the stream consumer has already disabled the draft
transport for the run, so a failed _seal_open_draft returning
success=False meant the user got NOTHING. Both seal-interception sites
(send + send_for_platform) now fall through to the regular plain-send
path on seal failure, with a warning receipt. Also mitigates AI-review
point 2 (sticky _open_draft_by_chat after an abandoned turn): a stale
entry's failed seal no longer blocks the next turn's delivery.

* fix(relay): arm seal-interception optimistically; never disarm on ambiguous failure (audit G-D1)

Deep-audit defect G-D1 (HIGH): the outbound leg is at-most-once on the
wire but its ack channel is lossy — send_outbound timeout (30s) and
WS-drop 'failures' frequently mean the frame WAS delivered and the
connector stream is open. send_draft popped _open_draft_by_chat on any
failure, disarming seal-interception while the connector stream lived:
the turn-final went out as a plain send → orphaned mid-word stream +
complete duplicate final (intermittent; needs a drop/timeout inside the
draft window).

Fix: arm the entry BEFORE the transport call and keep it armed on
failure/exception. Safe in every case: sealing a non-existent stream
opens+seals a single complete message connector-side, and a truly failed
seal already falls back to plain send at both interception sites.
Stale-entry damage is self-healing (one warning + plain send).

* fix(relay): gateway-side sealed-draft tombstone — G-D1 arming must not resurrect sealed streams

Regression fix on G-D1 (live: 'worse than before' — escalating frozen
prefixes). Optimistic arming had no seal-awareness: a straggler frame
arriving AFTER the seal re-armed _open_draft_by_chat for the already-
sealed draft_id; the next send was converted to draft(final=true) on the
tombstoned connector key, which CLEARED the connector tombstone (final
frame = new-turn signal), re-opened a stream with cumulative content,
and left it frozen — repeating per straggler: 4-5 escalating frozen
snapshots. Mirror the connector: _sealed_draft_by_chat records the
sealed draft_id per chat (tombstoned BEFORE the seal's transport call);
send_draft for a sealed draft_id is a success no-op (content already in
the sealed message) and never arms. A new turn's fresh draft_id arms
normally.

* fix(relay): key stream/card state per (chat, turn anchor) — parallel turns must not collide (finding #10)

Live finding #10 (Alice; three concurrent turns in one flat DM): all
coordination state was keyed per CHAT on a one-active-turn assumption.
Three parallel turns produced: turn B's task card merged into turn A's
(both were card 'turn:root' — reply_to is None in flat DMs), B left
cardless, and _open/_sealed_draft_by_chat clobbered across writers (3x
duplicate finals on the last turn). Per-turn machinery was correct;
the keys were not.

Fix: _draft_key(chat, metadata) = chat + the turn's thread anchor
(inbound stamps thread_ts = event.thread_ts or ts on every top-level
message, so each turn has one even in flat DMs). draft arming, seal
tombstones, both interception sites, and the task-card id all derive
from the same anchor. New trace test pins two interleaved turns:
distinct cards, own-stream seals, no leaked plain send, no cross-turn
tombstone drops (289 tests green).

* fix(gateway): preserve cumulative native stream across tools

* fix(gateway): consumer-declared final — the seal carries the true final

Three composed fixes for the Slack live-cards duplicate-final class:

1. finish(final_text): TurnRunner passes the completed final_response
   (verifier footer, completion explainer included) as the authoritative
   finalize payload. The native-stream seal delivers the TRUE final, so
   post-stream mutation no longer forks a corrective plain send (#11).

2. Interim-send contract: commentary and segment-tail sends carry a
   gateway-internal _interim_send marker; relay seal-interception skips
   them at both egress doors. A mid-turn interim send can no longer seal
   the live stream and orphan the real final into a duplicate.

3. Queued-follow-up lane reconciles an unconfirmed final by EDITING the
   consumer's delivered message in place (sealed stream = regular
   message, chat.update live-verified); plain send only as fallback.
   This was the actual duplicate lane in the parallel canaries — every
   duplicated turn logged 'final stream delivery not confirmed; sending
   first response' (subagent-completion queued inbound), not parallelism.

Also: draft frames stay prefix-stable gateway-side (no fence-closing, no
segment state reset, no commentary reset for stream-is-the-message
adapters; MagicMock-safe 'is True' guards).

* test+docs: streaming-contract coverage completeness + maintenance guidelines

Coverage: two gaps closed on the consumer-declared-final contract —
(1) send_for_platform (the delivery-resolver egress door) honors the
_interim_send contract: no seal, marker stripped before the wire;
(2) finish(final_text) on a turn that never streamed does not adopt the
final (delivery ownership stays with the gateway's normal send path for
non-streaming models / tool-only turns).

Docs: AGENTS.md 'Known Pitfalls' gains the streaming delivery contract —
the four invariants of stream-is-the-message adapters (prefix-stable
frames, consumer-declared final, interim-send marker, reconcile-by-edit),
each traced to its live incident, plus the live-probed Slack streaming
API ground truth and the MagicMock 'is True' guard-style note.

* fix(relay): seal transport failure must never silently lose the final (review B1)

Two halves of one silent-loss path, live-probed on the review branch:

1. adapter: _seal_open_draft did not catch transport exceptions. A socket
   drop at seal time raised out of send(), skipping the fail-open plain
   send entirely. Now: retry the SAME idempotent final frame once (the
   connector's sealed-key tombstone returns the original stream ts for a
   repeated final — a retry can never open a second stream or duplicate),
   then report failure so the caller's fail-open path runs.

2. consumer: the turn-final retry (elif not _already_sent) called
   _send_or_edit with finalize=False, which re-entered the DRAFT-FRAME
   branch. Its no-op dedupe compared the adopted final against the last
   unsealed frame, matched, and returned True with ZERO transport calls —
   final_response_sent went green, delivered_final_matches reconciled,
   the gateway suppressed its fallback, and the user never received the
   answer. finalize=True keeps this retry out of the draft branch.

Regression suite: tests/gateway/test_relay_seal_failure.py (3 tests).
Mutation evidence in follow-up verification: reverting either half sends
the suite red.

* fix(relay): draft ids unique across gateway incarnations (review B3)

The relay connector tombstones sealed streams by (channel, draft_id) and
keeps up to 512 of them; they outlive the gateway process. Relay gateways
are disposable BY DESIGN (scale-to-zero), and _draft_id_counter restarted
at zero every incarnation — so the first turns after every scale-from-zero
in a recently-active channel replayed already-sealed wire identities. The
connector answered those frames straight out of the old tombstone: zero
Slack API calls, the OLD message ts returned as the new turn's identity,
the new answer silently dropped while gateway-side flags recorded success.

Seed the counter from wall-clock milliseconds at process start. Ids stay
plain ints within the existing contract op; incarnations cannot overlap
for realistic turn counts and restart gaps.

Regression: tests/gateway/test_draft_id_restart_uniqueness.py — the seed
test fails on the old code (seed 0 is not epoch-scale).

* fix(relay): stream/card state keyed per TURN, not per thread anchor (review B2)

The thread anchor is the wrong coordination identity — simultaneously:

- too coarse: two parallel turns replying INSIDE ONE Slack thread share
  thread_ts. Live-probed on the review branch: turn A's final sealed turn
  B's stream with A's content while A's own stream stayed open, and B's
  final degraded to a plain send.
- too fragile: a flat DM with no thread metadata degraded to the bare
  chat id, re-creating the original finding-#10 collision the anchor was
  meant to fix.

_draft_key now prefers the triggering inbound message id (message_id /
reply_to_message_id — per-turn by construction; the gateway's Slack
thread metadata and the consumer's send path both stamp it), falling back
to the thread anchor, then the bare chat. The consumer stamps the same
reply_to_message_id on draft frames so frames and the turn-final resolve
to one key. Task-card ids share the derivation via _card_key (one helper
for send AND stop, so the stop always hits the stream the send opened).

Legacy resolver-lane callers with placement-only metadata still seal via
_match_open_draft's fallback — but ONLY when exactly one stream is open.
With several open, an identity-less send stays a plain send: a duplicate
message is recoverable, sealing someone else's stream is not.

Regression: tests/gateway/relay/test_relay_turn_keying.py (7 tests).

* fix(relay): stream-is-the-message is a Slack semantic, gate it on the descriptor (review B4)

draft_stream_is_message was hardcoded True on the relay adapter class,
i.e. for EVERY relay platform. The base send_draft contract is
Telegram-shaped — the draft clears client-side and the final arrives as
a separate real send that becomes the history message. With the flag
forced on, any non-Slack connector advertising the draft op had its
turn-final intercepted into draft(final=true): probed on the review
branch with a telegram descriptor, the op stream was
[draft(final=false), draft(final=true)] and NO send — no history message
would ever be posted.

Gate the flag on the negotiated descriptor platform (slack), and skip
arming seal-interception entirely when it is off. A future platform with
genuine stream-is-the-message native streaming should advertise it via
the descriptor rather than widening the platform check by guesswork.

Regression: tests/gateway/relay/test_relay_stream_semantics_gating.py
(4 tests: gating both ways, telegram final is a real send, slack final
still seals).

* fix(gateway): mark every mid-turn status lane interim — heartbeats must not seal the stream (review B5)

Seal-interception treats the first unmarked send to an armed (chat, turn)
key as the turn-final. The consumer's own interim lanes (commentary, tail
flush) carry _interim_send, but four gateway-side lanes that fire DURING
a streaming turn did not:

- long-running heartbeat (default every 180s — probed live: at 3 minutes
  it sealed the live stream with '⏳ Working — 3 min', the real final
  posted as a duplicate, and later frames were silently swallowed by the
  seal tombstone)
- inactivity warning
- plain-text approval fallback (button lane failed)
- background-review notice

Add _interim_metadata() beside _non_conversational_metadata and wrap all
four call sites. The marker is gateway-internal; the relay adapter strips
it before the wire (existing behavior, pinned by test).

Note for follow-up: the opt-out shape remains fragile — any FUTURE
unmarked mid-turn send lane re-creates this bug. Inverting the contract
(explicitly mark the one turn-final send) is the durable fix but touches
every adapter's final-delivery path; deliberately kept out of this
review-fix series.

Regression: tests/gateway/test_interim_send_lanes.py (4 tests).

* fix(gateway): interrupted/incomplete turns must not adopt the diagnostic as the stream final (review B6)

The finish(final_text) adoption gate checked only 'not failed', but the
interrupt/abort returns in agent/conversation_loop.py are
{completed: False, interrupted: True, final_response: 'Operation
interrupted during …'} with NO failed key. Adopting that diagnostic:

1. sealed the user's streamed partial answer over with the interrupt
   text (stream-is-the-message: the seal rewrites the whole message), and
2. recorded the diagnostic as the turn-final payload, so
   delivered_final_matches reconciled and the gateway suppressed its own
   error-delivery path — the diagnostic became the ONLY thing delivered.

Enumerated all 27 final_response-bearing return shapes in
conversation_loop.py: every non-happy-path shape carries completed:
False (several with a diagnostic final_response and neither failed nor
interrupted — retry exhaustion, truncation, codex-incomplete); the happy
path routes through turn_finalizer.finalize_turn (completed=True). Gate
is therefore: not failed AND not interrupted AND completed is not False.
Results lacking the completed key entirely (older callers/test doubles)
keep the previous behavior.

Regression: tests/gateway/test_stream_final_adoption_gate.py (6 tests,
incl. a source-level pin on the run.py call site).

* fix(relay): task-card transport failures degrade to failed SendResults (review B7)

send_native_task_card_progress and stop_native_task_card_progress let
transport exceptions escape. The stop runs inside the progress loop's
finally block on the turn-cleanup path, and the post-cancel awaits in
gateway/run.py caught only CancelledError — a socket drop during a card
publish/stop therefore aborted cleanup BEFORE the final-delivery
bookkeeping ran.

Three layers, outermost defends any adapter:
- both adapter methods catch transport exceptions and return failed
  SendResults (progress is advisory; the TurnRunner's text fallback
  already handles failure results)
- the progress loop's finally wraps the stop (best-effort; the connector
  seals orphaned card streams on its own via recycling/eviction)
- the cleanup awaits log-and-continue on non-cancellation errors so
  final-delivery bookkeeping always runs

Regression: tests/gateway/relay/test_relay_task_card_failures.py.

* fix(relay): a dying turn seals its native stream instead of orphaning it (review B8)

Stale-generation exits (/new, /stop mid-stream) and cancellations
returned from the consumer's run() with the native stream still open:

- the Slack message kept its live streaming indicator forever (the
  cancellation best-effort edit only runs when _message_id exists, and
  the native draft path deliberately keeps it None);
- the adapter's armed interception state survived the turn, so the next
  turn on the same key could inherit it and seal a dead draft_id.

New adapter op abandon_open_draft(chat, content): seals in place with
the text already on screen (the consumer passes its last delivered
frame) — the seal adds nothing and claims nothing; delivery flags are
never set, so the gateway's normal paths still own whatever happens
next. Best-effort by contract (failure reported, never raised); the
connector reaps truly orphaned streams via recycling/eviction.

The consumer calls it from both death paths: the stale-generation early
return and the CancelledError handler.

Regression: tests/gateway/test_stream_abandon_on_turn_death.py (4 tests,
incl. the next-turn-inheritance hazard).

* fix(relay): bound the draft/seal coordination dicts (review M1)

_sealed_draft_by_chat's key embeds a per-turn identity, so every
completed turn wrote a permanent entry — unbounded growth for the life
of a long-running gateway process (the docstring said 'one entry per
chat', which stopped being true when the key gained the turn anchor).
_open_draft_by_chat could grow the same way via abandoned entries.

FIFO-evict both at 512 entries — the same idiom as the sibling bounded
cache (_auto_thread_by_chat, capped at 256) and the same size as the
connector's own tombstone store. The straggler window the tombstone
exists for is seconds long; FIFO is more than enough.

Regression: tests/gateway/relay/test_relay_state_bounds.py.

* fix(relay): explicit connector rejection disarms interception; exceptions stay armed (review P3)

The G-D1 optimistic-arming change silently dropped disarm-on-failure
entirely: after an EXPLICIT connector rejection (success=False result —
not a transport ambiguity), interception stayed armed even though the
stream consumer disables the draft transport on that failure and falls
back to edit-based streaming. Its turn-final would then be converted
into a seal on a stream the connector just told us is unusable.
test_draft_failure_result_propagates claimed to cover this ('must NOT
leave seal-interception armed') but passed for an unrelated reason: the
stub's canned failure also failed the SEAL, whose fail-open path did the
plain send.

Split the two semantics and pin each honestly:
- explicit rejection (result success=False): disarm — turn-final is a
  real send (test_draft_failure_result_propagates, now testing what its
  comment says)
- transport exception: ambiguous, stay armed — turn-final still seals
  (test_draft_transport_exception_keeps_interception_armed, the G-D1
  contract)

Also corrects commit ba3a24a's claim ('a failed frame disarms
interception so the edit-based fallback's real send goes through
untouched') to hold again for the rejection case it described.

* fix(relay): lost acks are ambiguous, not rejections — on the RESULT channel too (review r2, finding 1)

The production ws transport does not raise on ack timeout — it returns
{"success": False, "error": "relay outbound timed out"}. The round-1
ambiguity handling keyed entirely on the exception channel, so the shape
production actually produces was misclassified as a definite connector
rejection. Probed on the head:

- lost SEAL ack: skipped the idempotent retry, fell straight to a plain
  send — duplicate final whenever the seal had actually applied;
- lost FRAME ack: the round-1 disarm-on-rejection fired — interception
  disarmed, frozen native stream beside a plain final. This re-created
  the original G-D1 ambiguous-ack defect on the result channel.

Contract now spans both channels:

- transport: the ack-timeout branch tags ambiguous=True. The fail-fast
  branches (closing / not connected) never sent anything and stay
  unmarked — they are definite non-delivery.
- adapter frame path: ambiguous results keep interception armed (same
  as exceptions); only definite rejections disarm.
- adapter seal path: one shared _attempt() classifier — exception and
  ambiguous result both mean "unknown"; the SAME idempotent frame is
  retried once (connector tombstone returns the original stream ts for
  a repeated final). Only after both attempts stay ambiguous does the
  caller's fail-open plain send run: a possible duplicate after double
  ack loss beats a silent loss, and double ack loss on one socket
  almost always means the transport is down for the plain send too.

Regression: tests/gateway/relay/test_relay_ack_ambiguity.py (6 tests,
incl. a source-of-truth check that the transport tags the timeout branch
and leaves fail-fast branches unmarked).

* fix(relay): stream semantics + draft capability resolve per CHAT, not per primary (review r2, finding 2)

One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate
per platform on the transport and egress is tagged per chat — but the
round-1 gate keyed draft_stream_is_message and supports_draft_streaming()
off the PRIMARY scalar descriptor. Probed on the head:

- Slack primary + Telegram chat: the Telegram chat's turn-final was
  intercepted into draft(final=true) — no real Telegram history message;
- Telegram primary + Slack chat: the Slack chat was denied native
  streaming entirely.

Resolve both through _descriptor_for_chat — the same per-chat machinery
max_message_length already uses (added for the identical class of bug:
the primary's 39000-char cap over-sending into Discord 400s):

- new stream_is_message_for_chat(chat_id) on the adapter; arming and
  NotImplementedError gating use it. The class attribute remains as the
  single-platform value and legacy-probe fallback.
- supports_draft_streaming() gains an optional chat_id kwarg (base
  signature updated; single-platform adapters ignore it). The consumer
  passes chat_id with a TypeError fallback for out-of-tree adapters.
- the consumer's four draft_stream_is_message reads collapse into one
  _stream_is_message() helper that prefers the per-chat probe
  (class-resolved, MagicMock-safe) over the attribute.

Platform-name inference ("slack") stays deliberate: a descriptor-level
semantic field is the right eventual contract but is a cross-repo wire
change — noted for the gg follow-up so future platforms advertise the
semantic explicitly.

Regression: tests/gateway/relay/test_relay_multiplatform_semantics.py
(5 tests: both starvation directions, scalar fallback, per-chat
capability gate).

* fix(gateway): split delivery + authoritative footer reconciles by suffix, not full resend (review r2, finding 3)

The _FINAL_TEXT adoption guard refuses wholesale adoption on split turns
— correct (#78541: sealed heads would repeat inside the tail) but it was
absolute: a post-split verifier footer never entered the ledger,
delivered_final_matches() reported a mismatch, and the gateway resent
the ENTIRE body+footer after the split chunks (the #11 duplicate class,
one level up).

When the authoritative final strictly prefix-extends the split ledger,
the missing suffix is the only undelivered content: append it to the
live tail and the ledger, so the finalize carries it and the recorded
payload reconciles. Non-prefix rewrites keep the full-resend fallback —
a rewrite cannot be patched onto sealed heads.

Regression: tests/gateway/test_split_final_suffix_reconcile.py (3 tests:
suffix rides the tail + reconciles, rewrite still mismatches, unsplit
adoption unchanged).

* fix(relay): cancellation mid-seal restores open state so abandon can close the stream (review r2, finding 4)

_seal_open_draft pops the open entry and writes the local tombstone
BEFORE awaiting transport I/O — correct ordering for the straggler race,
but CancelledError is not an Exception: a cancel during the await
bypassed all failure handling, leaving the remote stream live (visible
streaming indicator until connector eviction) while the local state said
'nothing open'. The consumer's abandon pass — added for exactly this
turn-death case — found nothing to close and no-oped.

On CancelledError: restore the open entry, drop the premature tombstone
(only if it is still ours), re-raise. The abandon path then seals the
stream in place with the on-screen text.

Regression: tests/gateway/relay/test_relay_seal_cancellation.py (2
tests: state restoration, and end-to-end cancel→abandon→remote seal).

* fix(relay): thread anchors are placement, not turn identity — revive the placement-only fallback (review r2, finding 5)

_match_open_draft's single-open-stream fallback was dead for its primary
intended callers: metadata carrying thread_ts/thread_id (placement-only
resolver lanes) was classified as having 'turn identity', so those sends
never reached the fallback — probed: a plain final posted beside the
still-open turn-keyed stream.

Only per-turn MESSAGE ids are identity now. Thread-anchored and bare
callers share the fallback: absorb into the chat's open stream when
EXACTLY one is open; stay a plain send when several are (duplicate is
recoverable, wrong-stream seal is not). Callers WITH a message id whose
key misses never fall back — their identity is authoritative and a miss
means the stream belongs to a different turn.

Regression: 4 new tests in test_relay_turn_keying.py (thread-anchored
seal, both ambiguous-stay-plain shapes, id-mismatch never steals).

* fix(relay): random process nonce for draft-id seeding (review r2, follow-up 6)

The epoch-millisecond seed (round-1 B3 fix) mitigates the restart-replay
class but is not a uniqueness guarantee: two gateways starting in the
same millisecond, a forked process inheriting the class state, or a
clock step backwards can all mint colliding wire identities against the
connector's per-(channel, draft_id) tombstone store.

Seed from secrets.randbits(49) instead: collision probability negligible,
no clock dependence, and ids + realistic per-process turn counts stay
comfortably inside the connector's JS number range (draft_id?: number,
2^53). Regression test now spawns two real interpreters and asserts
their seeds differ — the exact scale-to-zero restart shape, and both
start within the same second so a clock-locked seed would fail it.

* fix(relay): stamp per-turn Slack egress identity — cache is fallback only (R3-5)

The connector (gateway-gateway#210) fills chat.startStream's
recipient_user_id / recipient_team_id — required by Slack when
streaming to a channel — from metadata.user_id / metadata.scope_id.
The gateway stamped only slack_team_id per-turn and left user_id (and
scope_id) to RelayAdapter._with_scope, whose per-chat caches are keyed
on chat_id alone and overwritten by every inbound message: with users
U1 and U2 running overlapping turns in one channel, U2's arrival
overwrote the cache before U1's stream opened, and U1's stream carried
U2 as recipient_user_id.

_thread_metadata_for_source now stamps scope_id and user_id from the
turn's OWN source (setdefault — explicit values win), so identity is
turn-scoped data on the wire. _with_scope is unchanged and fill-only:
the caches keep serving restart/synthetic sends that carry no per-turn
identity, which is all they were ever safe for.

Mutation evidence: reverting the run.py hunk sends
test_thread_metadata_stamps_per_turn_user_and_scope and
test_concurrent_turns_carry_their_own_identity red; restore returns
green. The _with_scope fill-only tests pass on both trees (existing
correct behavior, now pinned against regression).

---------

Co-authored-by: Ben Barclay <ben@nousresearch.com>
2026-08-20 15:31:21 +10:00
hermes-seaeye[bot] e1d54926a1 fmt(js): npm run fix on merge (#90536)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 05:18:09 +00:00
Brooklyn Nicholson 9c12d2d6c3 feat(desktop): unfocused session panes recede
With two sessions tiled side by side nothing said which one you were in —
both painted at full strength, both composers looked live. The unfocused
surface now fades and desaturates as one layer (thread, timeline rail,
composer, header together), so the focused conversation is the one with
colour in it. Light and dark carry their own opacity; a single pane never
dims, since focus falls back to the primary's selection.

The sidebar gains the matching half: every session open in a pane keeps the
active band, the unfocused ones at reduced strength through their own mixed
token — a colour rather than row opacity, which would have dimmed the title
and status dot with it.
2026-08-20 00:12:20 -05:00
Brooklyn Nicholson db72b41485 fix(desktop): scrollbars stop carrying the theme accent
The thumb mixed from --dt-midground, so every list had a small tinted bar in
its corner competing with real accent-coloured UI. A new --dt-scrollbar-thumb
derives from the same colour with chroma forced to zero, keeping each theme's
lightness — so the thumb still sits correctly against its own surfaces, just
without the hue. Alpha steps and the Firefox fallbacks are unchanged.
2026-08-20 00:12:20 -05:00
Brooklyn Nicholson 83fac2cf2d feat(desktop): one theme list in the palette, with a mode toggle inside it
The picker split every palette across a Light and a Dark group, so a
built-in appeared twice and picking one silently set the mode too. It now
mirrors Appearance settings: light/dark/system rows, then every theme once,
applied on top of whichever mode is selected.

Mode rows preview on highlight like theme rows already did — system resolves
through the live prefers-color-scheme query, so it previews what committing
it would actually give you.
2026-08-20 00:12:20 -05:00
Gille cefeed4ca8 fix(a2a): expose schemas through tool describe 2026-08-20 10:41:12 +05:30
hermes-seaeye[bot] 7156d0d658 fmt(js): npm run fix on merge (#90523)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 05:00:29 +00:00
Brooklyn Nicholson a662d08f37 refactor(desktop): replace every window.confirm with the shared dialog
Ten prompts — deleting sessions, cron jobs, credentials, endpoints and
providers, plus the settings and memory resets — were raw Chromium modals:
unstyled, blocking, and nothing like the rest of the app. Lint now rejects
the native globals so they can't come back.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 21198d9401 feat(desktop): add confirm() as the imperative front door to ConfirmDialog
Handlers that need the answer inline had no way to reach the shared dialog
without hoisting state and a JSX mount into their component, so they all
reached for window.confirm instead. This mirrors notify(): a store action
carries the question, one host at the shell renders the real ConfirmDialog.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 5df9cd27ea feat(desktop): let ConfirmDialog carry a secondary action
The worktree removal prompt offers a third way out — hide the lane but leave
the worktree on disk — which is why it was still hand-rolled. One optional
slot between Cancel and Confirm covers it, and it keeps Confirm as the
focused button so Enter still means the destructive action.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 1e26c02de6 refactor(desktop): route cron delete and review revert through ConfirmDialog
Both were hand-rolled copies of the shared confirm — same two-button shape,
same busy/close beat — and neither answered Enter. Folding them in drops the
duplication and picks up the focus fix.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson bb0e9ee95a fix(desktop): confirm dialogs take focus so Enter confirms
The delete-session dialog opted out of Radix's autofocus, which left focus
on the sidebar row that opened it — Enter re-activated the row instead of
confirming, and ConfirmDialog's Enter handler never saw the key.

ConfirmDialog now focuses its own Confirm button on open. The existing Enter
test fired the key at the dialog node, so it passed over the bug; it now
fires at whatever actually holds focus.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson bfcfdb30d1 test(desktop): prove the status bar keeps its own right-click menu
The unit test covers the primitive contract — the marker survives Radix's
asChild Slot merge. This adds the end-to-end half: mount the real coordinator
next to the real status bar, right-click it, and assert the customize menu
opens while the app fallback stays shut. That is the assertion that fails on
a build where the two halves drift apart, and it holds regardless of how the
ownership marker is spelled.

Drops the hand-stamped DOM fixture that asserted the coordinator honors an
attribute the test itself wrote.

Co-authored-by: huklaa <huklaa@users.noreply.github.com>
2026-08-19 23:51:10 -05:00
aydnOktay 2d6d7c550f fix(desktop): keep Radix context menus when asChild overwrites data-slot
The app-wide context-menu coordinator recognizes surfaces that own a Radix
menu by `[data-slot="context-menu-trigger"]`. Radix `asChild` merges as
mergeProps(slotProps, childProps), so a child that sets its own `data-slot`
wins and the marker never reaches the DOM. The status bar footer is
`data-slot="statusbar"`, so the coordinator swallowed its right-click and
showed the window-verbs fallback instead — leaving every default-hidden
status bar item, the context meter included, unreachable from the UI.

Stamp a dedicated `data-hermes-context-menu-trigger` after `{...props}` on
ContextMenuTrigger and bail on that marker. Any asChild surface with its own
`data-slot` is covered, not just the status bar.
2026-08-19 23:51:10 -05:00
Brooklyn Nicholson b4f978d983 fix(nous): treat "takes no reasoning parameter" as a definitive no
Both the wire path and the picker only consulted the catalog's
`mandatory` flag, so a route the Portal lists as accepting no reasoning
parameter at all still got sent a disable, and still offered a Thinking
toggle in the model picker.

For a route it serves, the aggregator's own catalog outranks the
models.dev inference: `supports_reasoning: false` now suppresses the
disable on the wire and drops reasoning controls from the picker
entirely, so there is no disable left to describe.
2026-08-19 23:28:14 -05:00
Brooklyn Nicholson 9c0cd1add2 fix(nous): make "thinking off" stick on a cold start
Portal reasoning capabilities were held only in memory, so a process that
had not yet fetched them answered "unknown" — and on that answer the Nous
profile drops the disable rather than risk a 400. A short-lived process
(`hermes -p`, a cron job, a freshly booted gateway) is always in that
state, so every one of those runs silently ignored "thinking off" and
billed the user for reasoning they had turned off.

The parsed catalog is now mirrored to `cache/reasoning_caps.json`, keyed
by the URL it came from, and hydrated on a cold lookup without touching
the network. Every picker and pricing fetch already pulls that same
document, so they seed the mirror for free.

The catalog URL itself now resolves through the same ladder as the rest
of the Nous catalog reads (`NOUS_INFERENCE_BASE_URL` → credential base →
production) instead of being pinned to production, which had a staging
profile deciding the reasoning-mandatory question from prod's answers.
Keying the mirror by URL keeps those deployments apart.
2026-08-19 23:28:14 -05:00
Teknium 6851841112 fix(bot-mode): group chat opens as one room pane, not two (#89788)
Opening a Bot Mode group chat painted the room twice — once as a main-window
workspace tab (host.openWorkspace) and once as the in-panel fallback, because
the Bots pane rendered off $groupChatWorkspace alone. Two live panes with
independent drafts drove one shared engine, and the roster disappeared behind
the duplicate.

The in-panel room is the fallback surface, not a second copy: it now renders
only while no main tab owns the group. The selection atom stays set either way
so the roster row still highlights, and desktops without the door — or whose
door throws — keep the in-pane room.

Consolidates #89881, #90274 and #90398, which fixed the same bug.

Closes #89788

Co-authored-by: helix4u <helix4u@users.noreply.github.com>
2026-08-19 23:12:25 -05:00
Brooklyn Nicholson 145cd763ca feat(desktop): drag markdown table columns to resize them
A colgroup of percentages is the only state, so widths never touch the
cells: one <col> per column, table-layout fixed, and the browser does the
rest. A drag moves one seam and the pair either side trade width, so the
table box never changes size mid-drag — no reflow of the message around
it, no scrollbar appearing under the pointer.

Handles are markup inside each <th>; the table listens once and resolves
the grabbed seam from the DOM, so there is no context, no per-column
component, and no index threading. Tables stay in auto layout until they
are resized, and double-clicking a seam hands them back to it — the same
reset gesture the pane sashes use.

On a 43-row table a 40-step drag mutates 78 col[style] attributes and
touches no cell.
2026-08-19 23:10:25 -05:00
Brooklyn Nicholson e361e70b3b feat(desktop): keep markdown table column widths across turns and sessions
A markdown table has no id — it is re-parsed from text on every render, so
any resize state hung off the transcript dies on the next turn. Key the
record by a hash of the header row instead: the same table resolves to the
same key after a re-render, a session switch, or a reload, without the
transcript carrying anything.

Widths are percentages of the table box, never pixels, so a restored table
stays fluid in a narrow pane. The namespace is deliberately disposable —
one key, 64 entries, 7-day expiry, swept on first access. Losing it costs
one drag.
2026-08-19 23:10:25 -05:00
hermes-seaeye[bot] a72c9ca248 fmt(js): npm run fix on merge (#90461)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 03:21:47 +00:00