The roster menu gated "New Group Chat" on `activeSourceRoster.length < 2`
(local-source rows only) while the dialog it opens seats the FULL
multi-source roster (`selectableRoster = roster.filter(bot => !bot.ghost)`,
`canCreate = selected.length >= 2`). With one local bot and any number of
remote-connection bots the door stayed shut on a room the dialog would
happily create; users added a throwaway local bot just to click through.
The gate now counts the same selectable set as the dialog.
Live repro (real Electron + a real second `hermes serve` registered as a
remote connection): e2e/group-create-gate-remote-roster — base: menu entry
`aria-disabled="true"` with 1 local + 2 remote rows in the roster; head:
enabled, dialog seats Hermes + the remote Inbox, "Create Group (2)" enabled.
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: neil771 <neil@lannscapital.com>
Seeds the pre-isolation layout (hosted_room* rows in the root state.db,
written by the store's own writers), launches the REAL Electron app and asks
its spawned `hermes serve` backend over JSON-RPC: groups.list / groups.state /
groups.log for the old room. On origin/main the store is created empty and
every call answers 4114 "hosted room not found"; with the one-shot import the
room, its cursor and its event come back, shared-state.db exists and the
legacy state.db is left intact.
Docs: one sentence on where room state lives and that pre-existing rooms are
copied across once on update. Contributor mapping for L4XB, whose #109917
proposed the same import a little earlier (ATTACH + INSERT OR IGNORE into an
empty store); the marker-row variant from #109979 is the one landed here
because it also reaches installs that already created new rooms after the
upgrade.
Refs #109775
Co-authored-by: L4XB <lukas.buck@e-mail.de>
The primary profile renamed to a Bot Mode title ("Bobby") is @bobby to the
roster, autocomplete and the mention resolver, but buildGroupChatTurnPrompt
introduced it — to itself and to its peers — as @hermes. A member told
"You are @hermes" read "@bobby …" as someone else's message and replied
"(pass)", so the room settled with the addressed bot silent.
Use botMentionTag() (title-derived tag, falls back to the @handle) for the
viewer and the peer list; an unrenamed primary still reads @hermes.
Live repro: e2e/group-prompt-renamed-primary-handle.spec.ts fails on
origin/main (room log carries only the user line) and passes here (entry
authored by `default` saying "B"). Prompt hunk proposed by @chilledtonic
in #89720.
Mock inference gains a one-shot `E2E_CALL(<tool>)[<json>]` script so a spec can make
the REAL tool run from a real Bot Chat; the new spec creates a sender bot, seeds a
`writer` profile titled "Scribe", sends a DM to "Scribe" and asserts the rendered
tool result says "Message dispatched to @writer" (base: "No teammate named
'Scribe'"), plus the sender's state.db tool row.
A bot created moments before the room runs its canonical-chat intro turn in
the background (create-dialog.tsx createCanonicalChat kickoff); when it
lands, the roster fronts that bot's chat tab and yanks the center away from
the room, so a room-content assertion sees the reply as hidden or the
Activity button as missing and fails on an unrelated visibility check. Both
specs now re-select the room tab inside the wait, like the approval spec.
The #91706 spec located the "once" button page-wide. On a build without the
fix the Desktop is no longer on the room when the approval arrives (the
same server request also lands on the session-level approval surface and a
fresh draft tab takes the foreground), so the spec failed on "button not
visible" instead of on the contract it exists to prove — one
approval.respond per click, no footer Respond. The spec now re-selects the
room tab while waiting and scopes the card, its choice and the (absent)
Respond button to the room's approval card, so a base leg fails on the
respond-count assertion.
Three group-room turn-loop defects, each live-reproduced in the real
Electron app against a real gateway with mock inference:
- Hold classifier (group-rounds.ts): a stop/halt/pause token holds a member
only within two words of an @mention, so "@impl go, das ist halt ein Test"
is delivered instead of setting a sticky hold (#103893); addressing the
whole room (@all/@everyone) without a stop word releases every hold, so a
Stopped room wakes on "@all <task>" without the literal "resume" (#97740).
- Retained failure (group-turns.ts): the gateway keeps a failed turn under
session.resume.inflight as {status:'error'}; the room read that as live
work and slid the deadline to the 20-minute cap while the member looked
busy. groupSessionBusy/retainedGroupTurnError classify it as finished;
the foreground poll throws it into the failed-turn path (activity +
roster badge) and the harvester consumes the stranded marker (#92760,
diagnosis from #95103 by @hrnbld).
- Approval card (group-chat-parts.tsx): approval choices submit on click;
the clip-prone footer Respond button is gone for approvals (#91706).
Room message code blocks wrap instead of overflowing (#91857 by
@piskooooo).
Tests: invariant vitest cases in group-rounds/group-turns/group-chat-parts
(all red on base); three Electron specs under apps/desktop/e2e using two
new mock-inference triggers (gated rm -rf for a real approval prompt, a
non-retryable 401 for a retained failure). Docs: bot-mode.md § Groups.
Co-authored-by: hrnbld <hrnbld@users.noreply.github.com>
Co-authored-by: piskooooo <piskooooo@users.noreply.github.com>
package-lock.json records the apps/desktop workspace version; siblings bump
it with package.json. The spec's createAgent opens the new Bot's canonical
Bot Chat, whose late hydration fronted a "Draft" tab over the room and hid
the reply the spec waits on; bring the room tab back before sending.
Group rooms label members through two paths that predate owner-qualified
bot meta: the member header read `allMeta[b.name]` (and cleared remote
titles outright), and `groupSpeakerLabel(name)` — the "X is thinking…"
line, Activity rows and the round prompt — looked a raw profile name up
in `$botMeta`, whose keys are `connectionId::profile` (botMetaKey). Both
either missed (raw id / stale "Hermes") or hit a stale v1 relic, so
re-titling a Bot repainted the roster but not its rooms, remote members
lost their titles, and two same-named `default`s on different gateways
produced identical Activity rows.
Now the header title goes through `botRosterMeta(member)`, Activity
records `groupMemberKey(member)` (the route-qualified key), and
`groupSpeakerLabel` resolves a member key — or a raw name that exactly
one roster row carries — through the same `displayName(row,
botRosterMeta(row))` pipeline the Bots tab renders. When two same-named
members still resolve to one label, the connection label is appended.
The clarify/approval card badge deliberately keeps the plain name (it
matches its member through `memberKey`).
Live repro (real Electron, mock inference): e2e/group-transcript-owner-meta
— re-title a room member through Edit profile; base kept "Programmer" on
the transcript row and Activity, head shows "Infra Ops" on both.
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: fridde01 <carl@carltaylor.com.au>
Co-authored-by: zhongwater123 <805041391@qq.com>
The shared GroupMentionInput textarea was fixed at one row (rows=1,
max-h-40, resize disabled), so drafting or reviewing a long brief meant
scrolling through a tiny field (#95300). Size it from its content with
`field-sizing: content` — the idiom the Kanban drawer already uses —
bounded at min(50vh, 24rem) with internal scrolling past the cap, so the
transcript keeps its space. Applies to both the new-thread and the
reply-in-thread composer through the shared component; Enter-to-send,
Shift+Enter, IME guards and @mention completion are untouched.
Ported from PR #95308 (its target was the pre-TypeScript plugin.js) onto
the current group-chat-parts.tsx; one invariant test on the mounted
textarea and a Playwright spec that measures the real Electron composer
at one row, eight lines, and past the cap.
Through the shell renderer an e-mail address autolinks to a mailto rendered
by the shell's link component, which is also .ref-styled; only mention spans
carry data-ref, so the mention assertion counts those.
Real Electron + hermes serve + mock inference: send a message mixing a
seated-bot mention, @user, an e-mail address and an unknown @token; assert
exactly two .ref spans (agent, human) on the rendered line, the stored log
text untouched, then click the bot reply's "Reply to Programmer" action and
assert the composer reads "@programmer " (idempotent on a second click).
The spec hardcoded a /tmp rig directory for its four screenshots and left
console.log tracing in place. Screenshots now go through
testInfo.outputPath() like chat.spec.ts so they land in test-results/ on
any machine and CI; the debug logging and its helper are gone.
A job row in the Scheduled jobs (Routines) pane is a grid item, and grid items
default to `min-width: auto`, so a long nowrap title made the row as wide as
its text (~530px inside the 250px pane). The pane's overflow clipped the
enable/disable Switch and the delete control clean off the right edge and the
title was hard-cut with no ellipsis (#91623); the next-run label on the
metadata line lost its tail the same way (#89534). `min-w-0` on the card and
on both lines lets the row shrink to the pane, the title ellipsizes, and the
schedule pill / next-run label wrap onto two lines instead of being cut
mid-word.
Closing the pane with its ✕ remembered the dismissal in
`hermes.desktop.dismissedPanes.v1`, and nothing ever showed the pane again —
it only exists by being registered while Bot Mode is on screen, so the pane
was gone until a full layout reset (#102224). The plugin now drops that
remembered Close each time it registers the pane (new `host.undismissPane`
door — un-dismiss + adopt only, unlike `revealPane` it never fronts the pane
or un-collapses its zone), so re-entering the Bots tab brings the pane back
as the collapsed right-edge tab it first arrived as.
Live-tested in the real Electron app with e2e/bot-routines-pane-narrow.spec.ts
(fails on origin/main, passes here); the same run confirms the row's Switch
toggles the job in place (#95031 already fixed by 253b9d78c).
Co-authored-by: MicroWearld <MicroWearld@users.noreply.github.com>
Co-authored-by: worlldz <worlldz@users.noreply.github.com>
Co-authored-by: 686f6c61 <686f6c61@users.noreply.github.com>
The roster's right-click menu on a bot row still spelled `Pin to top` /
`Unpin`, `Hide` / `Unhide`, `Groups: …` / `Manage groups…`, the pin/hide
toasts, the metadata-load error toasts, the `This device` gateway label
and the attention-badge tooltips as English literals, so a zh/ja reader
saw English items inside an otherwise translated menu (#91667, residual of
#88798 / #91336 after the bundle landed in a7db531a2a).
Route every one of them through the plugin bundle (`b.bot.*`, all four
locales) and turn `BOT_ATTENTION_HINTS` into `botAttentionHint(reason)`,
which reads `botsText()` at render so the tooltip follows the active
locale. No behaviour change; strings only.
Tests: bot-row.test.tsx renders the menu under `zh` and asserts the
catalog strings appear and no English literal survives (red on base).
e2e/bot-mode-roster-localized.spec.ts drives the real Electron app with
`display.language: zh`: right-click menu + a fresh group row (red on base,
green here).
A bot created moments before the room runs its intro turn in the background;
when it lands the roster fronts that bot's chat tab and the reply text found
by getByText sits outside any room body (null closest()). Scope the locator
to a room body and re-select the room inside the wait.
`MessageTextContent` resolves `MEDIA:` paths against the ACTIVE gateway, so
a Connections Bot's `MEDIA:/path` in a cross-machine room was read from the
wrong machine (broken image, or a same-path local file). The room now passes
`media={false}` for remote-source members, leaving the directive as prose.
The live spec's LONG_LINE (~150 chars) never overflowed the message column,
so its "no clipped ancestor" assertion held on base too. The fixture is now a
600+ char unbroken token and the probe first asserts a scrolling `pre` with
scrollWidth > clientWidth exists before asserting nothing clips it.
Room-level vitest: the SDK mock exports a stub `MessageTextContent` and the
test asserts every body renders through it, with media resolution on for
local members and off for remote ones.
A bot's reply in a group room went through raw Streamdown. Its stock code
block lays the header and the body out as inline siblings, so the code sat
shifted right and its tail was clipped with no horizontal scrollbar (#91878);
the same raw path never ran the Desktop `MEDIA:` transform, so a bot's
`MEDIA:/path/file.png` showed as a plain path instead of an inline image or
player (#93728). The room now renders each entry with the SDK's
`MessageTextContent` (the 1:1 chat renderer plus `renderMediaTags`) — the
door the previous commit exports — behind a feature check, so an older shell
keeps the Streamdown fallback.
Live-tested in the real Electron app with
e2e/group-chat-code-block-and-media.spec.ts (fails on origin/main: the
`MEDIA:` directive is visible as text and the code block clips; passes here).
The SDK mock used by the group tests gains the new export so the feature
check exercises the fallback there.
Add "Open recent session" to the bot row's context menu (#93054). The
left click stays on the canonical Bot Chat — that is the bot's stable
identity — but multi-task users keep their live work in ordinary
sessions (cron runs, delegated jobs, `+` side threads), and reaching the
freshest one meant hunting the mixed sidebar.
No new backend surface: `profiles.list` already ships `last_session`
(the newest row session.list would show; hidden Bot Chat / group member
sessions never win, tool/kanban workers are denied server-side). The
item opens that id through the same host.openSession contract the
canonical open uses, as a tab in the bot's workspace on its own
connection route, and falls back to the row click when the profile has
nothing listable yet. No tabTitle is passed: the stored row titles the
tab (a tabTitle equal to the title would caption it as the bot, the
canonical-chat rule, hiding which session it is).
Two invariant tests on the open path plus a Playwright spec against the
real Electron app; docs paragraph; bundled-plugin version bump.
Group rows gain the Pin to top / Unpin context item bot rows already
have. The roster sort has honoured `room.pinned` since the room-order
work, but nothing ever wrote the flag, so a daily-driver room kept
sinking under fresher 1:1 chats (#89813). The pin lives on the room
record and rides the local group-chats persistence like `rosterOrder`
(sync: false) — presentation state stays out of the gateway mirror.
Also a Playwright spec that drives the real Electron app through both
group-row organisation paths (pin/unpin ordering + storage truth, and
filing into a user section via the row menu), plus the user-guide
paragraph, the contributor mapping for the salvaged commit, and the
bundled-plugin version bump the recent history uses.
createAgent opens the new Bot's canonical Bot Chat, whose late hydration can
front a "Draft" tab over the room; the composer was then hidden at the send.
Bring the room tab back before each interaction with its composer.
Playwright spec against the real Electron app and `hermes serve` with mock
inference: create a three-Bot roster, seat two in a room, open Group
settings → Manage members, prove the checklist is pre-checked, that Cancel
changes nothing, swap one member for another via the header door, then
verify storage truth (durable room descriptors AND each Bot's groups[]
metadata) and that the next unaddressed round seats exactly the saved
roster. Docs: the user guide describes the new Manage members door.
A profile created without a clone source copies the launch profile's
`model` block so it can run. When that block points at a custom
`providers:` gateway (self-hosted / local endpoint), `model.provider:
my-gateway` alone is "Unknown provider 'my-gateway'" on the new bot's very
first turn — created, but unable to run, the same on-arrival failure
#101885 / #94071 describe for the built-in case (fixed in 01a3e9a44c).
launch_model_seed() returns the model block plus exactly that provider's
definition; both the CLI seed (_seed_model_config, also behind the
Desktop's profiles.create) and the gateway inheritance path
(_inherit_launch_model) write the same shape.
Live repro: e2e/new-bot-config-on-disk.spec.ts (New Bot → Fresh profile →
Create; asserts the created profile's config.yaml on disk and that the
intro turn completes) — red on origin/main with the banner "Unknown
provider 'mock'", green here.
Drives the REAL Electron app's spawned `hermes serve` backend over its
JSON-RPC socket (groups.create → groups.send → groups.log until the round
settles) and asserts storage truth: `profiles/sentinel/runtime/
active_sessions.json` holds no `bot_room` entry once the turn settled. On
the pre-fix build the entry stays (updated_at == started_at) until the
backend exits — the state that locked a second room worker out (#106847).
The Bots pane screenshot is the rendered-surface control.
Docs: note that several room workers can share a home and that a member's
room session is held only for its turn.
Refs #106847
Real Electron + real serve backend + mock inference: the Desktop holds alpha's Bot Chat open,
a chmod-000 ticket is planted in alpha's bot_live_delivery mailbox, then a real DM is admitted
through tools.bot_live_delivery. Fails on the pre-fix backend (admission degrades to
PermissionError / ambiguous, the gateway poll crashes every cycle, nothing renders); passes
with the tolerant scan (the DM renders as an inter-agent card).
Part of #109820
Durable form of the live repro for #92506: seeds a profile whose profile.yaml
carries an unquoted ISO timestamp under ui_meta and asserts the Bots roster
renders rows instead of spinning forever. Fails on origin/main (0 rows after
60 s, profiles.list never answers); passes with the serialization guard and
producer coercion in this PR (3 rows in 2.2 s).
Drives the real Electron app: type 小助手, Create Bot enables, the roster
row reads 小助手 and the profile lands at profiles/u5c0f-u52a9-u624b with
the entered name as its title. Red on origin/main (button stays disabled).
The "Applies to <profile>" note under the chips still printed the canonical
slug after the chips switched to the Bot title / display_name, so a bot
called "Atlas Prime" in the roster and on its chip was announced as
"alpha" one line below. Resolve the selected profile's presentation label
through the same profileLabel() precedence; the slug remains the identity.
Adds the Playwright e2e that drives the real Electron app with two seeded
profiles (one Bot Mode title, one display_name), opens Settings and asserts
the chips and the note read the presentation names (#109686).
Gate reasoning_effort and fast behind the same switch as model/provider in
desktopSessionCreateParams (includeComposerSelection): a Bot-workspace tile
targets a different profile without switching the window's composer, so all
four fields of an unrelated session's pick would otherwise ride into its
session.create. Omitting them lets the bot profile's configured defaults apply.
The vitest added for this PR left the hoisted requestGatewayForAgent mock
with a recorded call (restoreAllMocks only restores spies), which failed the
next test's not-called assertion in CI ("keeps an unlisted named local
legacy-profile tile owned by its bare profile"). The test now resets that mock
and the composer atoms it set, and asserts the two new omitted fields.
New Playwright spec bot-tile-ignores-ambient-composer-model.spec.ts drives the
real app: open a bot's canonical chat, pick a second model from the composer
menu (the mock provider now lists extraModels; receivedModels records the
model of every completion request), Ctrl+T a side chat, send a turn, and
assert the inference request carried the profile default. Fails on main's
index.ts (request carried the ambient pick), passes on head.
Real Electron + real `hermes serve`: seed one archived+hidden row and one
plain archived control through SessionDB, open the archived view and
assert both rows render with their Unarchive button. Fails on the
previous `list_sessions_rich` (only the control row lists), passes with
the archived-only listing fix.
Attribute drive-level errors to the thread being drained, not the send
that created the queue. Reinsert repeat member failures in recency order
so the collapsed activity row cannot show an older sibling failure.
Cover both invariants and the repeated-refusal sequence in native Desktop.
Thanks to @kvnloo for identifying both review findings.
Keep failed-member exclusion across the room queue, rather than resetting
it per pending thread. A new user action after failure still permits a new
attempt. Share the drain activity epoch so a skipped queued thread cannot
hide the preceding member failure.
Proven red in real Electron: hold transport refusal, enqueue same-thread
and cross-thread sends, then release; old head submits three times, fixed
head once. Strengthen follow-up evidence with distinct provider replies,
exact public log order/count, and per-input inference counts.
Serialize room drives through their actual member completion, freeze input
watermarks by retained entry identity, and share the same completion path
with handoff continuations. Stop discards queued work without releasing an
active owner early; rename follows the existing room binding.
Observe stranded replies for the hard-cap duration plus grace after the
foreground wait, and retain unresolved failures in collapsed Activity.
Never automatically retry an ambiguous failed submit within the same drive.
Adapted from the queue and boundary approach in #92041 by @enwaiax and
harvest-budget approach in #107193 by @Finn763; #106502 by @wadib identified
failed-submit watermark consumption. The implementation retains current
numeric watermark storage, room lifecycle bindings and serial round limits.
Related: #92003, #105247, #100026
Keep the salvaged botHandle normalization, but remove the unconditional
hermes alias on remote default profiles: the parser's last-wins map
otherwise retargets a local @hermes handoff by roster order.
Consolidate regression coverage into two invariants for persisted primary
handles, both handoff directions and three-source qualified identity.
Capture real Electron screenshots, durable logs and source receipts;
exercise the reverse live handoff too. Document the repair and bump the
bundled Desktop patch version.
Real-Electron Playwright spec for #100406: a two-member room (primary
profile + code-farmer). The user addresses only @code-farmer; its
scripted reply @mentions hermes; the assertion is a `default`-authored
"B" entry in the persisted room log. On origin/main the room settles
after Code Farmer's line and the spec fails at that assertion; with the
mention-alias fix it passes.
The mock inference server gains a per-speaker script for group rooms:
`E2E_SAY(<handle>)[<line>]` tokens in the user's send answer the member
whose turn prompt opens with `You are @<handle>`; unscripted members
reply "(pass)". `{at}` stands for `@` so the script itself never
mentions anyone and round one only drives the member the user tagged.
* feat(desktop): give Button a loading prop that swaps label for spinner without layout shift
The label stays in the box, invisible, and the spinner is absolutely
centred over it, so a Connect or Approve button keeps its width while it
works instead of collapsing to a spinner. The approval bar had the same
thrash and moves onto it.
* refactor(desktop): one consent card for connectors and MCP setup
McpSetupTool rendered its own copy of the connector card's markup. It now
renders ConnectorCard for the pending question and ConnectorSummary once
settled, and the card gains what MCP needed: keyboard accelerators, a
source line, a question heading. The card also gets an avatar variant
(40px mark in the left gutter, text and buttons on one column) and a
collapseWhenSettled switch so a connector can stay a full card with a
green Connected pill in the action slot while MCP keeps its one-line
summary. Brand marks for Gmail, Calendar, Drive, Discord, Telegram and
Spotify; Slack via Tabler because simple-icons dropped the mark.
* feat(desktop): connector card drives the agent through manage_connections wait
The offer used to end in a Continue in chat button, and the agent, seeing
an unconnected status, would improvise around the app. Now the card does
what the TUI does. Clicking Connect opens the browser and sends one hidden
line telling the agent to park in manage_connections action=wait for that
slug and to never call connect again (a second link cancels the one being
signed into). Not now sends its own line. A hidden request that lands
while the turn is busy steers it, or queues if the turn just ended.
Which call owns the live card changes too: consecutive calls naming the
same apps are one exchange (connect, the wait, the status that follows),
and the first of the last exchange is the card, so the agent's wait no
longer demotes the card mid-authorization and mints a fresh one below it.
A targeted ask renders one or two bare cards; only a real catalog gets the
header, search and refresh.
* feat(desktop): onboarding connects apps in chat and keeps tasks finishable without them
The welcome chat knew connectors only as preferences to pick and wire up
later, so asked to connect Gmail it invented a Settings page that does not
exist. Both scripts now carry one rule set: status once, one batched
connect for every app named, the card is the ask so write a line and end
the turn, never route around a declined app with another client or
credential. The build handoff checks real connection status instead of
asserting none are connected, and the first task must be finishable, not
free of, the apps they picked. The connectors card explains what
connecting means and reports the count on its Continue button.
* fix(tools): resolve the Nous identity for share_auth profiles in the connector gate
A profile created with share_auth has no auth.json of its own and signs
in through the root store. Every other credential reader falls back to
the global root; the connector gate read HERMES_HOME/auth.json directly,
saw nothing, and stripped manage_connections from the profile's tool
list, so the welcome chat's agent truthfully reported the tool missing.
The gate now goes through get_provider_auth_state.
* fix(agent): name a provider retry backoff on the live status line
The retry status is buffered and replays only when every retry fails, so
during a 60s backoff after a 5xx the user saw a bare spinner. Right after
a connector sign-in landed this read as the agent going silent. The
backoff now also rewrites the live wait notice, which the desktop already
renders in the thread status row; it is transient and clears on recovery.
* test(desktop): connector rehearsal launcher and flagged connector spec
connector-rehearsal.mjs starts the real desktop and backend under a fresh
HERMES_HOME with no copied credentials, a fixed Vite port and CDP on 9344,
so the onboarding connector flow can be driven end to end by hand or from
outside. The Playwright spec covers the flagged connector step.
* fix(desktop): send the agent back into wait when the user keeps waiting after a timeout
The card's Keep waiting re-entered the poll but the agent's own wait had
timed out too and nothing told it to go back in, so it would start
talking mid-authorization. keepWaiting now fires onWaiting like connect
does. Tests also pin that an expired or revoked grant asks the gateway
for reconnect, not connect.
* style(desktop): blank lines in connector-flow test per lint
* feat(desktop): HERMES_SKIP_INTRO=1 / --skip-intro skips the first-run film
The intro is a one-time reveal, so anyone rehearsing the guided chat behind
it sits through it on every fresh HERMES_HOME. The flag rides the existing
launch-flags path (main → preload → renderer) next to guestOnboarding and
only gates isIntroRevealEnabled; the backend never sees it. The rehearsal
launcher sets it.
* fix(desktop): onboarding card Continue stays Done after the transcript rebuilds
The card kept its Done flag in component state. The hidden submit and the
turn-end hydrate both rebuild the message list, so the card remounted with
the flag false and Continue came back live, letting a step be answered
twice. The committed steps now live with the other onboarding answers,
keyed by step, and the first-build chip pick rides the same store.
remember_onboarding projects by key, so the new field never reaches USER.md.
* fix(desktop): no provider picker or free-tier chip over the guided first launch
Two sign-in surfaces leaked into the guide. A credential probe on the
setup profile (a free-tier token mid refresh, a session before its runtime
settled) hit requestDesktopOnboarding and dropped the provider picker over
the chat the user was in; and the statusbar free-tier chip sat there
offering a second sign-in the whole time. Both now yield while the gate
phase is cinematic, guided or handoff. The free tier is the provider for
those phases, and the guide offers sign-in on its own ready screen.
* fix(desktop): onboarding connector picks are real catalog slugs
The picker offered Spotify, GitHub and Stripe, none of which the deployed
connector catalog carries, and spelled Calendar and Drive with hyphens the
gateway does not use. A pick the build chat could not honour ended as
"Spotify isn't in the connector list" after the user had been told to
expect it. The list is now twelve slugs from the live status catalog,
spelled as the gateway spells them; GitHub is out (the terminal has git
and gh), chat channels stay on Messaging. Marks for the new entries; the
Google marks answer both spellings. The build runbook offers the picked
connections in its first turn rather than after the work is underway.
* fix(desktop): the free-tier ready screen never interrupts the guided chat
A readiness round fires when the layout pick assembles the window, and it
raised the free-tier ready screen over the conversation: the user was
dropped into the main app, dismissed it, and came back to a card they had
already answered. The guide is the introduction. The ready screen now
yields while the gate is cinematic, guided or handoff, and the notice is
acked the moment the guided chat takes the screen, not only when the film
does, so a skipped film no longer leaves it pending.
* feat(desktop): tour options that lead to building, and a fork that follows the tour
"Just the basics" and "Show me around" read as a click-through with no
exit; "I'll figure it out" read as declining help. Now Quick tour, Show me
everything, and Skip, let's build something. The script also folds the
fork into the same turn as the tour, so when the user closes the overlay
the next ask is already waiting instead of a transcript that ends on the
tour call.
* feat(desktop): the onboarding connector picker reads the live catalog
A hardcoded list, however carefully copied from today's catalog, is the
next drift. The picker now asks connectors.list through the same
session-owned RPC the connector cards use and offers exactly what the
gateway carries: a curated lead order puts the everyday apps first, chat
channels stay on Messaging, everything else is reachable by search. The
picks are gateway slugs, handed straight to manage_connections. No
catalog (toolset off, gateway unreachable) ends the step honestly with
Skip instead of inventing apps.
* test(desktop): the guided first launch never forces a sign-in
The acceptance criterion the guided onboarding was built to, as a test:
while the gate is cinematic, guided or handoff, the provider picker does
not open and a credential warning is dropped rather than deferred to the
next send. Outside the guide the picker opens as before. Red against the
tree before the guards landed (6 of 9).
* fix(desktop): a relaunch mid-guide resumes the guide, in the guide's shape
Closing the app during the guided first launch and reopening it booted the
normal shell around the persisted solo layout: the connecting splash, the
stock composer and model picker, a small window whose sidebars would not
open, while the gate still read guided. The gate now queues a kickoff for
the guided phase too (the kickoff adopts the existing guide chat by title),
takes the solo shape before the gateway opens rather than after, and the
connecting overlay yields to the guide's own opening. A typed reply in the
composer now closes an ask card and the first-build chips the same way a
click does; the layout card's Continue comes back Done.
* style(desktop): one answeredAfter helper for the ask card and first-build chips
* fix(desktop): the guide takes its shape on the tick the film ends, not after the window shows
Between the film and the greeting the full-size shell painted for a beat:
finishIntroReveal showed the main window, then the kickoff shrank it once
the setup profile answered. The listener on the intro's hidden edge now
takes the guide's shape (solo layout + small centred window) synchronously,
so the window is already the guide when it is shown. One takeGuideShape
owns the pair; kickoff and the boot gate call it idempotently.
* style(desktop): the 'nothing connects yet' line reads first on the connectors card
fetchJsonViaOauthSession and finalizeGatewayDownload still hand-rolled the
`Error("<status>: <body>")` + statusCode shape the helper was introduced to
consolidate, so the "shared by all three paths" claim was false on landing.
The download-transport source test now asserts the helper call.
Cold-launching against a gated remote gateway whose stored session has been
invalidated server-side alternated between the connecting state and the
recovery overlay; the Sign in button was only intermittently clickable.
Root cause: fetchJson() built a bare Error("401: ...") for HTTP failures on
the native-bearer path, dropping res.statusCode. Every downstream classifier
is shape-based (isGatewayAuthRejection, isServerSideHttpError, the
ensureNativeAccessToken 401 check), so the confirmed rejection looked like a
transport blip: withTransientRetries hammered it, gatewayTicketFailure used
the transport copy, startHermes tagged the boot retryable, and the renderer's
bounded boot-retry loop re-emitted running:true over the overlay on every
attempt. Separately, gatewayTicketFailure only ever set needsOauthLogin,
which isReauthRequiredError ignores, so even a structured 401 from the cookie
path never latched.
- api-transport: httpStatusError() is the one HTTP-error shape; fetchJson and
fetchPublicJson use it, matching fetchJsonViaOauthSession.
- mintGatewayWsTicket: the gateway never rotates a native bearer server-side
(dashboard_auth/middleware.py), so a bearer 401 gets ONE forced
/auth/native/refresh; a live refresh token retries the mint once, a dead one
drops the stored tokens and the rejection is confirmed.
- gatewayTicketFailure: a confirmed 401/403 is tagged isReauthRequired so
startHermes latches it and marks the boot non-retryable.
- startHermes: latches are set before the first await in the failure path;
updateBootProgress holds every update that is not a re-emit of the latched
failure until a recovery path clears the latch.
Tests: composition + main.ts source pins (remote-reauth-latch.test.ts), unit
coverage for the new helpers, and a Playwright e2e that boots the real app
against a fake gateway with a dead session and proves the overlay latches
once (one mint, one refresh, retryable:false, Sign in stays clickable).