100 Commits
Author SHA1 Message Date
Austin Pickett 2a86dd6462 ci(docs): keep retained Pages assets 3 hours and fail fast on oversize artifacts
Two days of retained hashed JS/CSS (100+ builds across en and zh-Hans) pushed the compressed Pages artifact to 1.23-1.26 GB, past the 1 GB limit, and the 11:09 deploy timed out syncing. The stale-HTML window the pool protects is ~65 minutes, so 3 hours covers it with margin. A size gate before upload now fails with the real number instead of a Pages timeout.
2026-11-05 10:13:26 -08:00
Austin Pickett a5f20c5c04 i18n(tui): mirror the /queue management keys into the bundled TUI packs 2026-11-05 12:34:55 -05:00
Austin Pickett c91bd19840 chore(tui): regenerate i18n key manifest for /queue verbs 2026-11-05 12:34:55 -05:00
Austin Pickett 67d47601bc docs(website): official search titles on docs home and install page 2026-10-29 14:37:04 -04:00
Austin Pickett 6d8b6cf2a6 test(desktop): pin tool error summary paths and zero title fetches
Covers a Windows CODEX_PROGRESS.md path and www.-prefixed local paths, and
asserts window.hermesDesktop.fetchLinkTitle is never called for filename or
explicit-URL errors.
2026-10-28 07:20:56 -04:00
Austin Pickett b8ac495fa1 fix(desktop): keep www.-prefixed local paths literal in explicit-only linkify
EXPLICIT_URL_RE accepted `www.` anywhere, so /tmp/www.config and
C:\Users\...\www.config still became https://www.config links in tool
errors and system messages. Require that `www.` not continue a path or word.
2026-10-28 07:20:56 -04:00
Austin Pickett 7ee6d99c4f refactor(desktop): move the transcript equivalence checks out of use-session-actions/utils
The tool-call comparison grew with the clarify re-arm fix and pushed
utils.ts past its line budget. chatPartsEquivalent, chatMessagesEquivalent,
chatMessageArraysEquivalent and chatReactionsEquivalent, with their
exhaustiveness guards, now live in message-equivalence.ts; utils.ts
re-exports them, so call sites are unchanged. Behaviour is unchanged.
2026-10-27 18:37:10 -04:00
Austin Pickett ee8335aed2 fix(desktop): keep two calls that share an id apart in the overlay merge
The overlay treated any two parts with the same tool name and id as one
call. With a constant id (llama.cpp), a finished terminal call replaced
a running one from the same turn, and an answered clarify replaced an
open one. Two parts are now one call when name and id match and either
their args match or neither has a result.

Found in review by @yoniebans.
2026-10-27 18:37:10 -04:00
Austin Pickett e15f9b9239 fix(desktop): repaint a tool row when a re-arm changes its args or binding
The view takes a new transcript only when chatMessageArraysEquivalent
sees a change, and chatPartsEquivalent compared a tool call by id, name,
result presence and timestamps. A clarify re-arm for the next card
changes only the row's args and clarifyRequestId, so after a resume or
a session switch the screen kept the first card's binding: Confirm on
card 1 left card 2 with no live form, and a chat-bar answer left card
1's settled answer up. Tool-call parts now also compare argsText and
clarifyRequestId.

The one-card-per-question tests now render what the view last
published, through the same check. That also shows the earlier
same-question and duplicated-call cases only held when something else
repainted the row.

Found in review by @yoniebans.
2026-10-27 18:37:10 -04:00
Austin Pickett 35a1239705 fix(desktop): keep another tool's call and the answered copy in the overlay merge
concatPartsByToolCall dropped every earlier tool-call part whose id
appeared in the live row, whatever the tool. Ids repeat across calls
(llama.cpp sends one constant id), so a later terminal call reusing
call_0 deleted an answered clarify call_0 and its Q&A. When only the
page's copy carried a result, the result-less live copy won and the
card went back to pending. Dropping the page's copy also moved the
text and reasoning that followed the call there above it.

Calls now match on id and tool name, keep their place in the page's
row, and take the live copy's fields unless only the page's copy has a
result. The doc comment now says which side wins: the live store's
copy, not the re-fetched one.

Found in review by @yoniebans and @unsupportedpastels.
2026-10-27 18:37:10 -04:00
Austin Pickett e76e8ca41f fix(desktop): render a clarify request only on the row the re-arm bound
Ownership of the live request was decided by question text, so a row
whose args covered the request followed it. Text cannot tell rows
apart: an earlier call left without its tool.complete can ask the same
question, and a duplicated copy of a call covers every card of its
batch. Both rendered a second live form.

The transcript re-arm already picks one row for each request. It now
records that on the parts: the picked row carries the request id
(clarifyRequestId) and every other open clarify row it saw carries ''.
ClarifyToolLive renders the live request only on the row carrying its
id; a row no re-arm has seen still falls back to covering the questions.
A card answered with its own Confirm keeps its own args while it waits
for tool.complete instead of showing another row's live question.

Found in review by @unsupportedpastels and @yoniebans.
2026-10-27 18:37:10 -04:00
Austin Pickett 37ead5dec0 test(desktop): clarify asked one card per question, through the real seams
Drives the card the way production does: the transcript re-arm parkClarify
runs, the composer's answerParkedCard, and the card's own Skip. The next
card shows after a chat-bar answer or a Skip, the next chat-bar line locks
that card's request, the row never drops to a bare tool row while its
args catch up, and an earlier call's answered row stays settled when a
later call asks.
2026-10-27 18:37:10 -04:00
Austin Pickett b0cdaa1ea3 refactor(desktop): move the overlay's tool-call merge into tool-parts
The activation overlay's per-tool-call merge pushed
use-session-actions/utils.ts and its test file past their line budget.
The merge is a tool-part helper, so it now lives in
lib/chat-messages/tool-parts.ts as concatPartsByToolCall, and its overlay
test has its own file. Behaviour is unchanged.
2026-10-27 18:37:10 -04:00
Austin Pickett 681346296c fix(web): write every page header slot from a layout effect
A passive-effect cleanup on the outgoing page runs after the incoming page's
layout effects, so leaving Sessions (the landing route) or Files still erased
the next page's header action, e.g. Channels' Restart gateway. Move the
remaining passive header writers (Sessions, Files, Plugins and Chat's title)
to useLayoutEffect, document the rule on usePageHeader, and cover leaving
Files and Sessions.
2026-10-27 14:27:04 -04:00
Austin Pickettandyoniebans 22b67fd50c fix(skills): manage_catalog reads a ClawHub install back through its card id
The hub index resolves a ClawHub hit to its bare slug, and the lock
records @owner/slug. HostInstaller.install_skill looked the lock up by the
id it was given, so a finished install raised its last printed line
("Files: skill-card.md, SKILL.md, _meta.json") and a second one raised
"Use --force to reinstall." instead of reporting already installed. Its
search rows marked the same skill installed: false.

Both now go through installed_entry, which matches a hit's exact id and,
for a ClawHub hit, its clawhub/<slug> card id. The runner passes the
source that resolved the row, so another source's bare id still matches
exactly.

Co-authored-by: yoniebans <jonny@nousresearch.com>
2026-10-27 10:56:48 -04:00
27e6375b41 fix(skills): a malformed ClawHub lock row no longer empties the installed map
A hand-edited lock row with source clawhub and a non-string identifier
(say 123) reached ClawHubSource._parse_identifier, which called .strip()
on it. The AttributeError hit _installed_hub_identifiers' catch-all, so
every installed skill read as not installed. catalog_identifier now
returns None for a non-string id, and the row keeps its exact key as it
did before.

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Co-authored-by: yoniebans <jonny@nousresearch.com>
2026-10-27 10:56:48 -04:00
Austin Pickettandunsupportedpastels 82d7e2dba7 fix(skills): match a ClawHub install by source, not through a bare slug alias
The installed map listed each ClawHub lock row under its bare slug as well
as clawhub/<slug>. A bare id carries no source, so with @alice/foo
installed, a `foo` from any other source would also read installed.

HubLockFile.by_identifier now keys a ClawHub row by its lock id and the
source-qualified clawhub/<slug> card id only. Desktop cards install that
id, so they need no change. The dashboard's hub search and featured list
carry a ClawHub skill's bare slug, so web/src/lib/skill-hub-installed.ts
looks a hit up under its own id, then under the id a catalog card would
install for it (skillCatalogInstallIdentifier), which differs only for
ClawHub.

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
2026-10-27 10:56:48 -04:00
Austin Pickett 9b93cfe64b test(desktop): a ClawHub card shows as installed when its lock row is owner-qualified
Renders the Capabilities catalog with the hub map the backend now returns for
two ClawHub installs. The Agent Git Oracle card takes the installed skill's
switch, and My Pdf (its SKILL.md declares name: pdf, so it cannot merge into
that row) reads Added. With the previous map, keyed by lock ids only, both
cards offered Add.
2026-10-27 10:56:48 -04:00
Austin Pickettandxxxigm f73b3147f3 fix(skills): ClawHub installs read as installed on catalog cards and in hub search
The lock records a ClawHub install as @owner/slug. A Desktop catalog card
installs clawhub/<slug>, and the hermes-index and hub search list the bare
slug, so the installed map never matched either one. The card kept offering
Install, and a second click exited 0 with "already installed".

_installed_hub_identifiers now also lists each ClawHub lock row under those
two ids, parsed by ClawHubSource's own identifier parser. Exact lock ids
still win over an alias.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
2026-10-27 10:56:48 -04:00
e136b4894c fix(desktop): 44px touch expand toggle; fade only prompts that overflow
The touch chevron rendered at 36px (size-9). The 44px floor and
user-select: none in styles.css named only aui_user-touch-edit, so the
toggle sat 4px above the Edit pencil and under the coarse-pointer target
size DESIGN.md asks for.

measureClamp flagged anything over two lines of the wrapper's 1.5
line-height, while the clamp box is four lines at 1.3. Three- and
four-line prompts got the fade and the new chevron with nothing hidden
(51/51 and 68/68 px at 390x844). Compare against the clamp's own
max-height, read from the stylesheet once, so the fade and the toggle
appear only when text is cut off. jsdom has no stylesheet and keeps the
two-line fallback.

The doc comment no longer says the clamp lifts on hover. It lifts only in
the edit composer (styles.css), as #145088 pointed out.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-10-26 15:52:50 -04:00
484de065a8 fix(desktop): app sounds mute toggle, None chime, silent OS notifications (#143117)
* fix(desktop): restore interface sound mute across windows

* feat(desktop): add a "None" option to disable the completion sound

* fix(desktop): silence OS notification sounds while app sounds are muted

* refactor(desktop): apply the sound mute where native notification payloads are built

* fix(desktop): say the sound mute also silences OS notification sounds

* test(desktop): trim sound-mute tests to regression invariants

---------

Co-authored-by: Eddo Jansen <eddojansen@gmail.com>
Co-authored-by: ldx-person <31948444+ldx-person@users.noreply.github.com>
2026-10-22 16:18:04 -04:00
Austin Pickett 92663ae805 feat(desktop): install with +, switch once installed; tier badges on the install dialog (#142708)
* fix(desktop): catalog card actions sit above the card-wide hit area

On compact and hero cards the action slot comes before the title button,
whose ::after stretches over the whole card and painted on top of it, so
clicks on the switch or + opened the details instead.

* feat(desktop): install catalog entries with +, switch only once installed

Install is one-way, so it is now a + button instead of a switch that also
meant enable/disable. Installed entries keep their on/off switch. Featured
cards and the detail spell it out as "+ Install". A plugin's Uninstall
moves from the header trash icon to a labelled row at the bottom of its
detail.

* feat(plugins): badge the install dialog with the catalog tier

The dialog called every catalog pick Verified. It now says Official,
Verified or Community. plugins.catalog.lookup returns the tier so
hermes://plugin/install?catalog= links badge the same as in-app picks.

* refactor(desktop): plugin Uninstall row is its own component

Keeps PackageRow under its code-health complexity cap.

* chore(i18n): regenerate desktop locale keys for catalog.install

* fix(desktop): "Install here" badges the catalog tier too

The third way into the install dialog opened it with a catalog name but no
tier, so a community package still read Verified. Read the tier from the
cached catalog by name; uncached keeps the generic badge. Also note why the
catalog path passes entry.source as the tier.
2026-10-22 11:28:08 -04:00
Austin Pickett 8f555f4f92 feat(desktop): drag sessions to pin / unpin in the sidebar (#142672)
* feat(desktop): drag sidebar sessions onto Pinned to pin, back to the list to unpin

* refactor(desktop): hoist sidebar drop-target props out of SidebarSessionsSection

* ci: retrigger

* fix(desktop): don't treat Pinned as a drop target for an already-pinned session
2026-10-21 21:08:03 -04:00
47b23fa048 fix(desktop): keep the approval-mode chip on the active profile mode (#142565)
* fix(desktop): sync the approval-mode chip when the gateway opens

The statusbar approval chip seeds an optimistic 'smart' default and fetches
approvals.mode from a mount effect. The shell mounts before the gateway
WebSocket is open, so at every cold start the request rejects with "Hermes
gateway unavailable"; the swallow (.catch(() => undefined)) hides it and the
effect deps ([profile, requestGateway]) never change when the socket opens —
the chip sits on 'smart' for the whole session even when the configured mode
is 'off', correcting itself only when a later session event reconciles the
shared store.

Gate the sync on gatewayState === 'open' and include it in the effect deps so
it runs when the socket actually opens (and again after a reconnect) — the
same pattern useStatusSnapshot uses for its boot legs.

Test: with gatewayState="connecting" no request is issued and the chip shows
the pre-sync default; rerendering as "open" runs the sync and the chip settles
on the configured mode. Fails on base, passes with the fix.

* fix(desktop): only the active gateway's runtime sets the approval-mode chip

applyRuntimeInfo takes the owner scope of the runtime info, and the chip is
updated only when that owner is the active gateway (same profile, same
connection, a missing connection id counting as local). A bare profile owner
counts as active only while the primary or a connection-less socket is active.
All five callers pass their owner.

Salvaged from #125174 onto current main, where the runtime side effects now
live in reportRuntimeInfoAppState; the owner is threaded through it.

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>

* fix(desktop): address review nits on the approval-mode chip fixes

Reword the runtime-owner comment to name how it differs from the session.info
rule, and add the blank line eslint wants in the boot-sync test.

---------

Co-authored-by: Muntasim Ul Haque <106990977+muntasimulhaque@users.noreply.github.com>
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-10-21 11:29:54 -04:00
Austin Pickett d0e5cf43a8 Merge remote-tracking branch 'origin/main' into mergemain-138820
# Conflicts:
#	apps/desktop/src/app/capabilities/plugins/plugins-tab.tsx
#	apps/desktop/src/i18n/en.ts
2026-10-20 08:54:46 -04:00
Austin Pickett 93600451dd feat(catalog): featured hero becomes a slider when there are several picks
featuredPicks (apps/shared) returns every curated pick starting at this
week's rotation, so slide one is still the website's pickFeatured entry.
CatalogFeaturedSlider shows one pick bare; several get scroll-snap slides
(swipe/trackpad), prev/next and dots, advancing every 7s unless hovered,
focused, hidden or reduced-motion. Off-screen slides are inert.
2026-10-20 00:35:46 -04:00
Austin Pickett 68de2c251d feat(desktop): several picked categories show as one shelf each
Selecting two or more categories keeps the shelved layout: one section
per picked category (small ones included, no folding into More to
explore, no tier split, no hero). A single category stays the flat
drill-in grid; search, tags and Installed still flatten.
2026-10-20 00:22:11 -04:00
Austin Pickett 36400de0e8 feat(desktop): tier filter keeps plugins grouped; every chip on reads as All
Filtering Plugins by tier keeps the discovery shelves instead of a flat
grid: Official and Verified stay their own sections and Community keeps
its category shelves (the hero pick drops while a tier narrows the page).
Picking the last unselected chip of a facet clears it, so selecting all
three tiers is just All.
2026-10-20 00:06:52 -04:00
Austin Pickett de44b64dd3 feat(desktop): filter and badge plugins by tier (Official, Verified, Community)
On the Plugins tab the filter bar's second facet is Tier, ordered
Official > Verified > Community, and installed-only values (bundled,
user, git, desktop kinds) fold into those three via pluginTier. Skills
keep the Source facet (their registry). Card and detail badges tone the
trust tiers like the installed-plugin pill: Official primary, Verified
success with a check.
2026-10-19 23:24:27 -04:00
Austin Pickett 2b1440bd49 fix(website): curated skill art points at the docs site, not GitHub raw on main
extract-skills.py stamped raw.githubusercontent.com/.../main/... URLs, which
break if the art moves on main. The art ships in website/static/img/catalog/,
served at hermes-agent.nousresearch.com/docs/img/catalog/; one named constant.
2026-10-19 22:57:46 -04:00
Austin Pickett 07d7925009 refactor(desktop): name the catalog's inline page sizes, stall timeout and gaps
CATEGORY_PAGE, LOOSE_ROWS_PAGE, VISIBLE_CHIPS, RELATED_COUNT,
TOOLSET_FEATURE_COUNT/MIN and CATALOG_STALL_MS (the timeout message reads
it, so the two can't drift). The filter bar's and section heading's
literal halves become --capabilities-gap-sticky / --capabilities-inset.
2026-10-19 22:57:44 -04:00
Austin Pickett 115b0fccd4 refactor(catalog): one official-skill-source list and one docs origin
OFFICIAL_SKILL_SOURCES / isOfficialSkillSource live in apps/shared and
replace three hand-kept copies (desktop curation, desktop discovery, the
website skills hero). catalog-data reuses CATALOG_ORIGIN instead of its
own DOCS_ORIGIN copy. The plugin hero's official check reads the tier.
2026-10-19 22:57:43 -04:00
Austin Pickett 0a203ed9e8 fix(desktop): route the catalog's discovery, developer and Tools copy through i18n
Tier headings (Official / Verified Plugins), More to explore, Skills for
<category>, the Discover heading, the developer banner, the Tools feature
row, toolset group names and the tool/call counts were English literals.
They now live in catalog.* (en_catalog.ts); other locales fall back to
English via overlay-gaps until translated. The developer banner's three
links move to named constants in lib/plugin-catalog.ts.
2026-10-19 22:57:42 -04:00
9cb34e9e28 fix(desktop): read the final reply aloud after tool calls (#140623)
* feat(desktop): give each spoken message in a turn its own key

A turn with tool calls can speak several messages: short progress
updates, then the final reply. The turn key alone cannot tell them apart.
spokenSegmentKey adds a text fingerprint, which every window computes the
same way and which a message id rewrite does not change.

* fix(desktop): read the final reply aloud after tool calls

Auto-speak claimed `speak:<turnKey>` across windows, and the arbiter holds
speech claims for 10 minutes. Once a progress update in a turn was read
aloud, the final reply of the same turn asked for the same claim when that
clip ended, was refused, and returned silently. It was already marked
spoken, so nothing retried and no TTS request was made.

Claim, and join in-flight playback, per message instead. An id rewrite
keeps the text, so the double-speak guard from #121560 still holds. Log
the refused claim so a drop is no longer traceless.

* fix(desktop): key a spoken message by its last text part

Hydration folds a tool-call turn's assistant rows into one bubble, so the
bubble's full text becomes progress + final. Hashing the full text gave a
window that only saw the hydrated history a different key from the window
that read the live final bubble, and both read it (#99717 again).

The bubble's last text part is the same in both, and still differs between
a progress update and the final reply. Test runs a stored tool turn through
toChatMessages and checks the keys match.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>

* fix(desktop): one window reads every message of a turn

Each renderer's busy check only sees its own playback. With the same chat
open in two windows, A read the progress update while B stayed idle; when
the final reply arrived B was free, won the new per-message claim, and
read it over A.

The window that wins a turn's first message now claims the turn
(`speak:turn:<turnKey>`) and voices its later messages after its own clip
ends. Other windows skip the turn. Test drives two renderers with separate
playback stores against the real claim arbiter.

Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>

---------

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
2026-10-19 18:15:27 -04:00
Austin Pickett 12429b2f30 fix(desktop): external-dir skills stay editable in place
Restore main's skill-provenance helper (#108032, commit 8c8fc6c1ec):
skills mounted through skills.external_dirs are editable and archivable
in place, not read-only. The catalog branch had dropped the helper and
gated editing on provenance === 'agent'.
2026-10-19 16:03:58 -04:00
Austin Pickett 0b3593a561 fix(desktop): Agent on turns a unified package's desktop half on again
Main turns the desktop half on when the agent half is switched on
(enablePackageDesktopHalf with keepUserChoice), so one switch enables a
unified package. The catalog's PackageRow lost that in the conflict
merge; restore it and main's two regression tests (on together, and a
desktop half the user switched off stays off).
2026-10-19 16:03:57 -04:00
Austin Pickett adbdb1bc23 chore(desktop): drop the embedded catalog picker's leftovers
The card catalog replaced the iframed docs-site picker on Capabilities ->
Plugins. Remove what only that pane used: CATALOG_PICKER_URL and the
catalogTitle / catalogBrowse / catalogHide / catalogHint strings (5
locales, types, overlay gaps, keys manifest). The plugins.json feed, the
hermes://plugin/install deep link and catalog installs are unchanged.
2026-10-19 13:00:30 -04:00
Austin Pickett ff3d4ab615 refactor(desktop): move externalOpenFailed copy into an i18n sibling
Offsets the catalog keys' growth so en.ts and types.ts stay under the
code-health line caps main set (5103 / 4302).
2026-10-19 10:04:29 -04:00
Austin Pickett 94c5aaa11e Merge origin/main into austin/feat/capabilities-catalog 2026-10-19 09:59:18 -04:00
Austin Pickett be8c674744 refactor(desktop): keep payload runtime helpers in payload-backend
Moves the bundled ticket runtime and requireBundledPayload out of main.ts
so the ticket-minting fix nets main.ts down, under the code health ratchet.

(cherry picked from commit 0097904d0cbdb6336ef780dd1bbcc62fdb9e53ff)
2026-10-19 03:01:17 -07:00
Austin Pickett d8395b9cf8 Merge origin/main into austin/feat/capabilities-catalog 2026-10-18 23:04:44 -04:00
Austin Pickett a8d70670e1 feat(catalog): feature the Claude SDK provider plugin
Adds claude-subscription-directsdk to the head of the featured plugin
rotation (weekly, shared by the site and the app).
2026-10-18 22:53:49 -04:00
Austin Pickett 6a962f76ef feat(desktop): featured hero shows the entry's artwork
The hero skipped the image even when the entry had one. It now fills the
right 42% edge to edge (cover), fades into the hero's wash on its left,
and the stars/action footer stays in the copy column clear of the art.
Entries without art keep the plain wash.
2026-10-18 22:53:48 -04:00
Austin Pickett 28cd6a7f28 fix(desktop): pad the sticky catalog filter bar's bottom edge
The Category / Source / chips / Filters row sat flush on the bar's edge,
so scrolled cards met it with no gap.
2026-10-18 22:53:47 -04:00
Austin Pickett c4c8e600b6 fix(desktop): a click during the chooser check keeps its sign-in window
The chooser short-circuit read hiddenRecovery before awaiting the cookie
check. A Sign in click that joined in that gap revealed the window, then
the pending check still finished the attempt as SIGN_IN_REQUIRED and
destroyed the window the user was looking at. Re-check after the await.
The deadline message now uses the same constant.

Reported-by: unsupportedpastels
2026-10-18 19:39:46 -04:00
Austin Pickett 42ba4b731a chore: map contributor email for Jaylouisw 2026-10-18 19:39:46 -04:00
Austin Pickett 5907cb1a8d fix(desktop): unattended sign-in never shows a window
A refused background request (sidebar polls, roster, ticket mints after
an overnight lapse) answered its 401 with a silent login window that
revealed itself after 2.5s whenever auto-SSO could not finish, so a
locked Mac collected sign-in windows nobody asked for. The REST 401
retry and the boot cloud cascade now run hidden with the 12s deadline;
on failure the 401 reaches the app's existing Sign in surface. Only a
user action (Sign in, Use gateway, picking a cloud agent) shows a
window.
2026-10-18 19:39:46 -04:00
Austin PickettandJaylouisw 09e67682ae fix(desktop): one OAuth login flow per cookie jar
Key the shared login on (partition, origin), the scope one cookie jar
serves, not origin alone: two same-origin gateways on separate jars
must not share a result or a backoff.

A user's click during a hidden attempt now reveals that window (and
drops its unattended deadline) instead of opening a second one that
would replace the gateway's PKCE state cookie and fail the first
callback with state_mismatch. Background joiners stop waiting after
15s, so a window left open cannot stall every request for the gateway.

The per-origin relogin map in oauth-rest-request.ts goes: the flight
owner is the only place that decides, and joiners skip the cookie
clear while a flow is running.

Co-authored-by: Jaylouisw <26432845+Jaylouisw@users.noreply.github.com>
2026-10-18 19:39:46 -04:00
Austin Pickett 8c917bb7f6 test: use the hermes_yaml seam in the non-finite retention test
PyYAML is not a pinned test dependency (pyyaml = false in pyproject); bare
`import yaml` fails under the canonical test runner's environment. The repo's
YAML seam `hermes_yaml` provides the same safe_dump.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
(cherry picked from commit 96ae9f3163125d6659222ddc010af851c32d407a)
2026-10-16 15:03:56 -07:00
Austin Pickett d08b187cf1 feat(desktop): Verified tier comes from the catalog feed
#139983 added tier: verified to the plugin catalog. The discovery shelf
reads it from the feed instead of the placeholder curated list, and is
titled Verified Plugins to match the tier name used everywhere else.
2026-10-16 18:00:25 -04:00
Austin Pickett 96efa73d9f Merge origin/main into austin/feat/capabilities-catalog 2026-10-16 17:53:55 -04:00
Austin Pickett 3357b095fc feat(desktop): order catalog shelves by stars
Cards within a shelf sort by stars then name; artwork only decides which
shown card takes a showcase slot. Community shelves rank by their
most-starred entry.
2026-10-16 11:01:28 -04:00
Austin Pickett ec5deb9cce feat(desktop): group plugins into Official, Trusted and Community tiers
Official is the feed's official tier. Trusted is a curated allowlist in
catalog-curation.json (placeholder picks until the trust policy and feed
tier are settled). Community keeps its category shelves.
2026-10-16 11:01:25 -04:00
Austin Pickett 13851f7d18 feat(desktop): plugin cards show their artwork 2026-10-16 11:01:20 -04:00
Austin Pickett f57e46207d test: run the drive-letter volume parse on the Windows lanes
The Windows jobs only collect `-m platforms` tests, so the unmarked parse test
ran on Linux and macOS, where `Path("/workspace")` is absolute either way. Add a
`platforms("windows")` twin.
2026-10-16 08:50:13 -04:00
Austin Pickett 5c7615824f fix(cli): classify unmapped container paths in the requested profile's scope
Translation ran inside the requested profile's home + terminal scope, but the
Windows 404-or-drive decision and the sandbox hint read the active profile's
backend after that scope reset. With mixed backends a missed docker path could
reach the drive lookup, or a local profile's host read got the sandbox 404.
Both now run under one `_agent_path_scope(profile_home)`.

The same scope also installs a bound home's terminal policy when the caller
already set the override (a native profile ticket) and no scope is bound, so
translation stops reading the launch env's TERMINAL_DOCKER_VOLUMES there.
2026-10-16 08:50:13 -04:00
Austin Pickett 671363fa34 fix(cli): treat profile=current as the request's own profile on fs reads
`_fs_profile_home` sent every non-empty `profile` to `_cron_profile_home`, which
has no `current` alias, so `/api/fs/list` and `read-text` 404'd ordinary host
files for `profile=current`. Use `_is_current_profile` (None, "" or current),
as the rest of the dashboard does.
2026-10-16 08:50:13 -04:00
Austin Pickett 581eb6184d fix(cli): gate fs container-path translation on docker, host-first, per profile
Review follow-ups on the fs / managed-file container path mapping (#85238):

- Translate only when the docker backend is active, so a local-backend gateway
  with leftover docker_volumes keeps serving real host files.
- POSIX: a readable host path wins; translation runs only when the host
  lookup misses, so a root-run gateway can still browse its own /root.
- Windows: a container-shaped path that misses translation is a 404 instead
  of falling back to <drive>:\workspace\x.
- Bind the requested profile's home and terminal policy before translating,
  like MEDIA delivery (#109024), so profile=work resolves in the work sandbox.
- Pass quiet=True from the fs routes so ordinary host reads no longer log
  "Docker MEDIA path ... did not resolve" warnings.
- Keep refusing '..' in managed reads: paths with '..' are never translated.
- The "may live inside the agent sandbox" 404 detail only shows for
  /workspace, /root or a configured volume target.
2026-10-16 08:50:13 -04:00
Austin Pickett 23f3fb72a0 test: restore the SSH download test path to main's
The fake SSH backend ignores the path; the edit was unrelated to this PR.
2026-10-16 08:50:13 -04:00
Austin Pickett 0108ed7b86 fix(gateway): keep drive-letter docker volumes on Windows gateways
The parser split `C:/data:/workspace` correctly but then dropped the mount on
`Path("/workspace").is_absolute()`, which is False on Windows. The container side
is POSIX on every gateway and already starts with '/'. Move the parse test out of
the linux-only class so the Windows lanes run it.
2026-10-16 08:50:13 -04:00
6af4dff06d fix(cli): resolve agent container paths on the fs and managed file reads
Docker agents emit container paths (/workspace/..., /root/...) in transcripts
and MEDIA tags. The dashboard's /api/fs/* routes and the managed /api/files
read routes checked those paths against the gateway HOST filesystem, so the
preview, download and file browser 404'd or 403'd on sandbox files (#85238).
A persistent docker agent's /root/fileIwant.zip lives on the host at
<sandbox>/docker/default/home/fileIwant.zip. Clicking it returned "File is
not readable" because the gateway tried to read its own /root.

- `_agent_fs_path` runs the gateway MEDIA translator on the raw string before
  `_fs_path` normalizes it. Otherwise, on a Windows gateway, /workspace/x
  became D:\workspace\x and could silently serve an unrelated host file.
  When no terminal scope is bound (serve --isolated), it retries once under
  the profile's terminal policy and falls back to the plain host path.
- read-text, read-data-url, download (including the session_id cwd path) and
  list resolve through it before `_hosted_fs_read_guard`, so the locked-root
  check sees the real host target.
- `_resolve_managed_path(for_agent_read=True)` translates before the
  locked-root containment check. A translated target outside the root still
  gets a 403.
- A container-shaped path that fails translation under docker 404s with
  "may live inside the agent sandbox" instead of a bare not-found.

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: fangliquanflq <fangliquan@qq.com>
Co-authored-by: jacklhchan <32115938+jacklhchan@users.noreply.github.com>
2026-10-16 08:50:13 -04:00
Austin Pickett e74f2b8621 fix(gateway): accept Windows gateway hosts in the docker path translator
Container paths are POSIX on every gateway. `Path("/workspace/x").is_absolute()`
is False on Windows, so the translator refused every container path there.
The volume parser also split `C:/data:/workspace` on the drive colon, which
dropped drive-letter host mounts. Check for a leading `/` on the posix form,
and skip a drive prefix before looking for the container separator.
2026-10-16 08:50:13 -04:00
Austin Pickett bde2eed2d5 chore(catalog): compress catalog artwork under the 500 KiB binary cap 2026-10-15 10:21:17 -04:00
Austin Pickett ceb2a5543a refactor(desktop): split catalog components under the complexity target 2026-10-15 10:20:15 -04:00
Austin Pickett f90af63649 refactor(desktop): move catalog copy into i18n *_catalog siblings 2026-10-15 10:12:18 -04:00
Austin Pickett b0b696667b fix(desktop): plugin gear keeps the Settings ▸ Plugins hand-off 2026-10-15 10:07:47 -04:00
Austin Pickett 3b1c1508d2 fix(catalog): re-introduce the catalog module and adapt to main
main reverted the Skills/Plugins catalog in #131252 (reverting #119381).
This change brings the module back alongside the redesigned catalog:

- restore apps/desktop catalog files deleted by the revert that the
  redesign builds on (browser, cards, detail, filters, download, pointer,
  store, skill-catalog, update-skills-button, masonry/reel primitives)
- restore apps/shared catalog-browse.ts / catalog-install.ts and their
  exports (merged with main's newer exports)
- restore the catalog i18n namespace (en + types) and regenerate
  locales/_keys.desktop.json
- keep main's newer plugins work: Settings ▸ Plugins settings pages and
  profile hand-off, requires.gpu server tone, the package-folder join
  from the unified name resolver, and the in-tab PluginSettingsForm
- keep main's segmented-control no-wrap fix; add the branch's sizing prop
- port the branch's additive UI primitive APIs onto main's versions:
  OverlayNavItem leading/pressed, SidebarPanelLabel meta, Button chip
  variant, CheckboxMark, SearchField leadingContent/box variant
2026-10-14 23:59:57 -04:00
Austin Pickett 9aaf2fda3c refactor(kanban): split board metadata/lifecycle into kanban_db_boards; fix ratchet findings
Move board-metadata and board-lifecycle functions (board_metadata_path,
read/write_board_metadata, _default_board_display_name, _dir_holds_board,
create_board, list_boards, remove_board) out of the kanban_db facade into
the topical sibling hermes_cli/kanban_db_boards.py, offsetting this PR's
facade growth past its main-pinned FILE_LINES cap (moved code keeps its
cap; re-exported from the facade tail so imports/monkeypatching are
unchanged).

Also drop the blind except Exception in the dashboard's _ws_board_live:
read_board_metadata never raises (missing/malformed board.json yields the
synthesized entry), so the archived-tombstone check needs no try/except.
2026-10-07 17:21:31 -04:00
Austin Pickett 1a85226e22 fix(kanban): dashboard rejects stale archived/deleted board slugs
_resolve_board 404s archived tombstones, and the /events WebSocket closes
stale-tab streams (1008) at the handshake instead of handing the dead slug
to connect(), which resurrected an empty board (#43243).
2026-10-07 17:21:31 -04:00
Austin Pickett cae57adfa0 fix(kanban): connect()/init_db() refuse to recreate dead boards
Refuse to create a fresh DB for a slug that is archived (tombstone
board.json) or does not exist (no board.json) — read/watch paths (dashboard
tabs, gateway notifiers, event streams) handed a stale slug used to mkdir an
empty DB-only stub that reappeared as an active board (#43243). Explicit
creation goes through create_board, which writes metadata first; the
current-board context resolving to an archived dir is refused too.
2026-10-07 17:21:31 -04:00
Austin Pickett 63e5656409 fix(kanban): board identity requires board.json; archive leaves a tombstone
A directory holding only kanban.db (no board.json) is a stub, never a
board: _dir_holds_board/list_boards/board_exists now require the metadata
file, so historical DB-only ghosts stop surfacing as empty active boards
(#43243).

remove_board(archive=True) leaves an archived tombstone board.json at the
original slug so stale readers can distinguish an archived slug from a
never-created one; create_board clears the tombstone (explicit creation
wins), get_current_board falls through archived slugs, and boards switch/rm
explain the tombstone in their output.
2026-10-07 17:21:31 -04:00
Austin Pickett 0b70608a58 test(gateway): venv import setup must not leak PYTHONPATH into global environ (#57467) 2026-10-07 17:19:01 -04:00
Austin Pickett 6869d009c9 fix(desktop): the link-title window cannot become visible
On Windows Desktop the hidden tier-2 link-title BrowserWindow has still
painted a blank top-level frame over the chat during browser-tool turns
(#64867). Encode the invisibility contract in the launch options —
off-screen bounds, skipTaskbar, focusable: false — and belt-and-suspenders
it with setOpacity(0) + hide() after creation, best-effort like the audio
mute.

Salvaged from #66523 (thanks @stantheman0128).
2026-10-07 17:13:33 -04:00
Austin Pickett 98900360e7 fix(browser): pin local Chromium headless on Windows
Local mode is documented as zero-cost headless Chromium, but nothing pins
that contract: any environment or config that resolves agent-browser's
--headed setting to a window turns a browser-tool turn into a visible
blank top-level frame over the Windows Desktop chat (#64867).

Extract windows_headless_browser_options(options, is_windows) — pure so
the invariant is testable on any host — and wire it into both local
Chromium creation paths: _spawn_and_collect (main session dispatch) and
_run_chrome_fallback_command (temp fallback session). It pins
AGENT_BROWSER_HEADED=false only on Windows, only for local launches, and
never overrides an explicit AGENT_BROWSER_HEADED (the opt-out stays the
user's). Headed mode (browser.headed / AGENT_BROWSER_HEADED) keeps its
intentional --headed argv.

Fixes #64867
2026-10-07 17:13:33 -04:00
Austin Pickett cc11102273 docs(web): note the dashboard build's resource caps (#63338) 2026-10-07 17:13:11 -04:00
Austin Pickett af9c7137c8 fix(web): bound the dashboard build's CPU and heap (#63338)
The dashboard build (Vite 8 + Rolldown + Tailwind v4) saturates every CPU
on small hosts (200%+ top readings, OOMs and frozen SSH sessions on 2–4
vCPU VPS boxes). Rolldown exposes no JS-level worker knob, but its native
binding parallelizes with rayon, which honours RAYON_NUM_THREADS.

Cap both sides of the build, everywhere it runs:

- scripts/build/web.mjs (the npm run build / packaging entry): limit the
  native bundler's rayon pool to half the available cores before vite is
  imported, and re-exec once with a --max-old-space-size cap (sized from
  the cgroup memory limit, 75%, floored at 1 GB, ceiling 4 GB) unless
  the user already set one.
- hermes_cli/web_build_limits.py + build_source_web(): the same caps for
  the install/update/dashboard-triggered source build path.

User env always wins (existing NODE_OPTIONS heap flag or
RAYON_NUM_THREADS is preserved); HERMES_WEB_BUILD_MAX_OLD_SPACE_SIZE /
HERMES_WEB_BUILD_THREADS tailor the caps, and HERMES_WEB_BUILD_LIGHT=1
tightens them (1 thread, 1 GB heap) for hosts that cannot spare full CPU
during the build — the light-build idea from the closed PR #78131.
2026-10-07 17:13:11 -04:00
Austin Pickett 9d7a4ab718 fix(dashboard): hide AuthWidget on 401 via ApiError status
fetchJSON now throws ApiError with a humanized message, so the
widget's legacy `msg.startsWith("401:")` check never matched and a
401 from /api/auth/me rendered "auth status unavailable" instead of
nothing. Read the status off the error object.

Part of GH issue 66223 (loopback /api/auth/me).
2026-10-07 17:13:07 -04:00
Austin Pickett 649d6c0391 test(desktop): fold the MEDIA pdf card case into the existing media suite 2026-10-07 17:05:12 -04:00
Austin Pickett dbbc8d7947 test(desktop): pin the preview height clamp's viewport growth and ceiling 2026-10-07 17:05:12 -04:00
Austin Pickett 7b9ce36a21 fix(desktop): widen the inline image preview envelope so stacked image sets stay viewable 2026-10-07 17:05:12 -04:00
Austin Pickett 39a01d069d fix(model-picker): keep custom provider rows and the current model honest across switches
Three defects in one class, backend + renderer:

- _lap_bare_custom_row only built the bare `model.provider: custom` row
  while the session was ON it; switching away made it vanish and the
  unconfigured-canonical fallback substituted a 0-model placeholder
  (#59702). Recover the endpoint from the config.yaml `model:` block when
  the caller's current-provider slice points elsewhere; is_current is then
  true only when that slice points back at the URL, and the live probe
  stays gated on refresh / probe-current.

- The Desktop Settings model page kept the previous provider's model
  selected after a provider switch (withActive() painted it as still
  selectable, and Apply would pin the new provider to the old model),
  and a named custom_providers entry the user just switched to showed 0
  models because the normal non-refresh options load probes only the
  CURRENT custom provider (#59063). A user-driven switch now clears the
  model and fires one refresh-scoped options fetch for an empty
  configured endpoint.

- groupModels derived model families solely from the provider catalog;
  an OpenRouter current model the catalog omits left the picker with no
  active-model row (#57534). Prepend the current model when the catalog
  lacks it.

Tests: red on base for the bare-custom regression, the settings switch,
and the missing-current-model row; green with the fix.
2026-10-07 17:04:44 -04:00
Austin Pickett 3adc457de5 Merge remote-tracking branch 'origin/main' into sid/onboarding
Conflicts, all in apps/desktop/src/i18n:
- de/en/es/fr/zh.ts, types.ts: import blocks only. Main added the
  *_projects section split, this branch the *_onboarding one; kept both.
- en_shared_metrics, zh_shared_metrics, ar_common, zh-hant (usage-stats
  consent copy): kept this branch's rewritten copy. Main's only change on
  its side since the merge base was a prettier reflow of
  collectedReliability (2943ee6f19), which the branch's rewrite replaces;
  types_shared_metrics on HEAD already matches the branch's keys.

Follow-through: main's Undo-hide toast (sidebar.projects.hiddenFromSidebar /
undoHide) arrived without ar or zh-hant strings, which this branch's
overlay-completeness test rejects. Added both to ar_chrome / zh-hant_chrome
and regenerated locales/_keys.desktop.json.
2026-10-07 16:16:23 -04:00
Austin Pickett 354724fb08 style(desktop): sort the projects-sibling imports per perfectionist 2026-10-07 16:08:56 -04:00
Austin Pickett bf8cf34c50 refactor(desktop): move sidebar projects copy into per-locale siblings for #73091
The undo-hide toast strings pushed 8 locale catalog files past their
main-pinned FILE_LINES caps (they may only go down). Offset the growth
the AGENTS.md way: extract the existing sidebar.projects block from each
over-cap file (en/de/es/fr/ja/ru/zh/types) into a topical sibling
<lang>_projects.ts following the <lang>_boot / <lang>_model_menu
convention. No key changes; locales/_keys.desktop.json verified unchanged.
No behaviour change — pure code move.
2026-10-07 16:08:56 -04:00
Austin Pickett ec60e9a6d8 test(desktop): store-level dismiss/restore round-trip for auto projects
Adds a layout-store test beside the existing menu test: dismiss hides
the row, restore brings it back, restore is idempotent. Also drops an
unused helper the salvaged test file left behind.
2026-10-07 16:08:56 -04:00
Austin Pickett 734c35bbc7 fix(desktop): translate the undo-hide toast strings (de/es/fr/ru) for #73091
The salvaged fix only shipped en/ja/zh; the other bundled locales fell
back to English. Adds hiddenFromSidebar/undoHide for de, es, fr, ru.
2026-10-07 16:08:56 -04:00
Austin Pickett ef0c635f33 refactor(web): move session source config to SessionsPage_sources sibling; satisfy code-health ratchet 2026-10-07 11:18:50 -04:00
Austin Pickett d199cc2465 test(web): behaviour coverage for structured reasoning transcript rendering
RED on main: the expanded session transcript renders assistant
reasoning with literal <thinking>/<action>/<result> wrapper tags.
Keeps the user-role invariant (literal tags stay on the Markdown path).
2026-10-07 11:18:50 -04:00
Austin Pickett 25dfcbf514 chore(desktop): refresh locales/_keys.desktop.json for cron.queuedRun 2026-10-07 10:18:52 -04:00
Austin Pickett f7b6b4ec89 refactor(desktop-i18n): move billingBlock copy into en_billing sibling
Offset the cron queuedRun string growth in the over-cap en.ts/types.ts
facades per the code health ratchet: moved code keeps its cap.
2026-10-07 10:18:52 -04:00
Austin Pickett 44c1ad7f84 fix(desktop): show a queued cron run row immediately after trigger
Run History only rendered sessions the backend had already materialized,
so an accepted Trigger now was invisible for the tens of seconds the
scheduler took to create the run session (#70826). The queued row is
painted from the click, settles when Run History observes a run that
started after it (any load — a fast 1s poll while queued, the cron.changed
broadcast on event-capable backends, or the visibility poll), and drops
at a bounded 90s timeout so a never-materializing run cannot wedge the
button. A failed request drops the row immediately. Pause deliberately
does not settle it: the pause response only persists enabled=false and
never cancels an already-claimed execution.

Queued design (queued-run row + polling + settle semantics) originates in
#70840 by @nv-cho; this ports it onto current main's
createCronTriggerController wiring.

Fixes #70826
2026-10-07 10:18:52 -04:00
Austin Pickett 85d2e49aac test(model-catalog): pin inclusionai/ling-3.0-flash:free in both picker sources
RED on base: the curated fallback list and the published manifest both omit
the model, so the Desktop picker never offers it even though the CLI accepts
it as model.default (#73686).
2026-10-06 17:23:23 -04:00
Austin Pickett 86df3224e2 fix(pricing): wire kilocode into the pricing dispatcher
Kilo Gateway models showed no prices and no Free badge because
_PRICING_FETCHERS had no kilocode branch, so get_pricing_for_provider
returned {} and _apply_pricing skipped the provider row entirely.

Add _fetch_kilocode_pricing, which reads the public OpenRouter-shaped
catalog at {kilocode profile base_url}/models (KILOCODE_BASE_URL
overrides it — the same endpoint the doctor probe uses, no second
hardcode). The Kilo catalog's explicit isFree flag is authoritative
over the rates (a free router can carry non-zero underlying pricing),
and its -1 rate sentinel (auto-router models, 'rate varies') is
skipped rather than shown as a fabricated price.

Fixes #68838
2026-10-06 17:22:46 -04:00
Austin Pickett c061068b26 fix(dashboard): authenticate the gateway health probe without leaking the key to /health
The dashboard's _probe_gateway_health() sent no Authorization header, so an
auth-protected gateway (API_SERVER_KEY set) answered 401 to every
/health/detailed poll and logged a warning per probe. Read API_SERVER_KEY at
probe time and send it as a Bearer header — to the authenticated
/health/detailed endpoint only; the public /health fallback never receives
the credential.

Fixes #76051
2026-10-06 17:22:39 -04:00
Austin Pickett 0f0b0a12aa fix(plugins): refuse checkout mutations while the gateway is live
Git plugin update/remove/force-reinstall ran git pull or tree swaps
directly in the installed checkout while the messaging gateway could
still be running with that plugin's callbacks loaded. A callback that
defers a relative import until first invocation then fails with
ModuleNotFoundError even though the command reported success (#70473).

One shared guard (hermes_cli.plugins_cmd._refuse_live_gateway_mutation)
now fails every mutating path BEFORE any git operation when the active
profile's gateway is live:

- plugins update (CLI) and dashboard_update_user_plugin
- plugins remove / rm / uninstall, dashboard_remove_user_plugin and the
  plugins.manage RPC (shared _remove_user_plugin tail)
- plugins install --force (a forced reinstall replaces a loaded
  checkout; a brand-new install is not gated)

The liveness read is the canonical profile-scoped probe
(profiles._check_gateway_running -> resolve_gateway_liveness), never
mutating the profile's identity files; a probe failure fails open so an
uninstallable plugin cannot strand. The message gives the
stop/apply/start recovery sequence; --allow-live-gateway on
update/install/remove is the explicit CLI-idiom override.

Integration test (tests/hermes_cli/test_plugin_live_gateway_guard.py)
spawns a real child holding gateway.lock with a gateway-shaped command
line against a temp HERMES_HOME, so the guard is exercised through the
platform's actual liveness probe — not a mock. Stopped-gateway behaviour
is asserted unchanged.
2026-10-06 17:22:18 -04:00
Austin Pickett 96c64b4526 fix(desktop): key Webhooks state and REST to the active backend profile
The Webhooks view keyed React Query by the profile scope but kept every
profile-owned local state (create draft, create dialog, one-time created
secret, pending delete, restart/error banners) in plain component useState,
and its REST calls resolved the mutable global profile at execution time.
Switching the active backend profile with the page open leaked the old
profile's draft dialog into the new profile, and a delayed create
completion could surface the old profile's one-time webhook secret under
the new profile view (#71352).

Bind the whole operation to one captured backend owner:

- Split WebhooksView into a thin wrapper that keys a ProfileOwnedWebhooksView
  by the normalized active gateway profile: a profile switch unmounts the old
  operation scope synchronously, so drafts, dialogs, secrets and pending
  state disappear at the same boundary as their owner (AGENTS.md: a profile
  swap is a re-home; the previous context must not leak into the next).
- Route all Webhooks REST calls (get/enable/create/delete/toggle), the
  gateway restart request and its status polls through an explicit profile
  argument captured at mount, instead of ambient mutable profile
  resolution. Optional arguments keep the ambient scope for callers
  without an owner.
- Guard every async completion with a StrictMode-safe ownerActive ref
  (set in effect setup, cleared in cleanup) so a stale profile's create/
  toggle/delete/restart completion cannot notify or repaint the new
  profile view.
2026-10-06 16:59:52 -04:00
Austin Pickett 4a4a92c215 test(desktop): webhooks profile ownership invariants (red on base)
Regression coverage for #71352: the create draft must be dropped when the
backend profile changes under All profiles, a create completion owned by the
previous profile must not surface its one-time secret or toast, and a normal
create completion must still land after React StrictMode's effect rehearsal
(the lifecycle bug flagged on #71538's cleanup-only ownerActive effect).
2026-10-06 16:59:52 -04:00
Austin Pickett 609affef45 fix(desktop): multi-select folders when adding to an existing project (#68741)
The add-folder dialog picks through pickProjectFolder, which hardcodes
multiple: false, so an existing project takes one folder per interaction.
Add a plural pickProjectFolders that enables the native dialog's
multiSelections, and addProjectFolders, which writes each picked folder
(excluding ones the project already has and repeats within the pick) in a
single submit beat. The create flow reuses the same multi-pick to extend
its accumulated folder list; single-folder pickers are unchanged.
2026-10-06 16:59:32 -04:00
Austin Pickett 73f7625c59 refactor(desktop): owner holds + $projectTree leaf out of session-states, breaking the projects cycle
The #76535 diff grew session-states.ts past its main-pinned FILE_LINES cap
(3264 > 3226) and its ./projects import pulled store/gateway into every
suite that partially mocks the gateway, killing check:test:ui. Offset per
AGENTS.md: the create→foreground owner-hold block moves to topical sibling
session-owner-holds.ts (moved code keeps its cap), and $projectTree moves
to leaf project-tree.ts so session-states reads it without the gateway edge;
projects.ts re-exports it, so every existing import path is unchanged.
2026-10-06 16:19:59 -04:00
Austin Pickett ff7b9d1a2c fix(desktop): resolve a cold focused tile's cwd beyond the recents page (#76535)
$focusedWorkspaceCwd's tile branch searched only the paginated $sessions
recents page, so a restored cold tile older than that window (and outside
the project preview caps) resolved an empty cwd and Files showed no
project, even while the row sat in a loaded sibling store. Resolve the
tile's session row through the same loaded-row space the owner lookup and
tileStoredRow already use — recents + cron + messaging + unlisted owner
rows (where a hidden bot chat's resolved row is parked on purpose) + the
project tree's preview/lane rows — keyed by the tile's durable stored
session id, never by persisting the cwd on the tile identity (cwd is
backend-authoritative state another surface can change; the renderer only
caches it). Unresolved secondary focus still fails closed: no rung falls
through to the primary's cwd.
2026-10-06 16:19:59 -04:00