Commit Graph

5951 Commits

Author SHA1 Message Date
OcuClaw
035171c7c9 fix(desktop): never clone a unified package's desktop half standalone on a local backend
The install dialog gated the package path on the agent install succeeding in
the same click (`agentInstalled && desktopHalfFromPackage`). A package already
on disk answers "Plugin '<name>' already exists. Use force reinstall" without
Force, so the retry fell through to installDesktopPlugin and cloned
desktop-plugins/<git-name>/ beside the package copy the app had already
materialised as desktop-plugins/<manifest-name>/. Two folders, one plugin id:
the on-disk duplicate from #100412 (since e1a6679399 an error row instead of
a competing live instance, but still created by the install flow).

The package path now applies whenever the repo is a unified package, the
backend is local and the Agent box is ticked, whatever the agent install
returned. reconcileDesktopPlugins() is idempotent, so a retry touches
nothing; the desktop success toast is raised only when the agent half landed
or a copy was actually materialised, so a refused install shows the agent
error alone. A failed fresh install no longer leaves a standalone desktop
clone behind for the next successful install to duplicate.

Remote backends keep the separate clone (their plugins/ folder is not
readable from this machine); the Desktop-UI-only tick is unchanged.

Fixes #100412 (install-time half; runtime half landed in #118902).

Tests: two invariants in plugin-install-modal.test.tsx, the first red on
main (installDesktopPlugin called on the refused retry), the second guarding
the remote-backend clone.

(cherry picked from commit 9fbd3a9d8328ea452a4149252a35a0e6f43982e6)
2026-09-24 06:59:49 -05:00
brooklyn!
09f4adc705 fix(desktop): do not classify a signal-killed ssh child as unreachable
runSsh dropped Node's close signal, so a null exit code looked like a
plain failure. Every close consumer now keeps that signal. An empty
stderr signal death is not an unreachable host.
2026-09-24 06:23:43 -05:00
djohonson
b3ee10187e fix(desktop): fail loudly when a built renderer chunk is not valid ESM
- assert-dist-built now parses every emitted dist/assets/*.js chunk as an
  ES module (node --input-type=module --check via spawnSync) before the
  build reports success
- a silent bundler output loss (observed twice: a 10-byte identifier token
  missing from the main chunk) previously shipped a syntactically invalid
  bundle that white-screens Electron with Uncaught SyntaxError
- the loud failure feeds the existing update-path honest-retry and
  stage-and-swap, so a corrupted build retries instead of shipping
- vitest coverage: invalid chunk fails with the chunk name in the error,
  valid chunks (incl. import.meta / template literals) still pass

Fixes #105184
2026-09-24 06:23:28 -05:00
brooklyn!
d909c697f0 fix(desktop): collect artifacts referenced with #media: hrefs
Chat emits file refs as `[label](#media:<encoded path>)`, but the
Artifacts collector classified the raw href and dropped it. Decode
those hrefs before looksLikeArtifact so Windows and POSIX paths,
image markdown, and explicit tool artifact keys are collected.
Legacy MEDIA: tags and plain URLs are unchanged.

Same decode as pull 88158; that patch did not apply cleanly.

Fixes #88084

Co-authored-by: SayHell0W0rld <18085695+SayHell0W0rld@users.noreply.github.com>
2026-09-24 05:54:10 -05:00
brooklyn!
850931b4ca fix(desktop): gate Close Tab, Reload, and Zoom on keyDown
before-input-event fires for keyup as well as keydown. A Ctrl+W that
started in another app delivers its keyup when Windows focuses Hermes,
and the shortcut handlers treated that as Close Tab.

Refs #105498
2026-09-24 05:54:02 -05:00
brooklyn!
9647f3505b fix(desktop): keep inline-code MEDIA closers out of the path (#105212)
The bare-word fallback still used \S+, so relative paths and unknown
extensions swallowed a trailing backtick. Stop that alternative before
backtick and double-quote, and keep the anchored extension branch.
2026-09-24 05:53:37 -05:00
brooklyn!
fbff2b8a7a fix(auth): audit API 401s and name stale app-token mint failures
A bearer 401 from the dashboard gate was returned to the client but
never written to dashboard-auth.log. Record session_rejected with the
client-facing reason, path, and IP, and never the bearer.

When that rejection is the app's saved bearer, the desktop mint error
says the app token is invalid instead of telling the user to
re-authenticate the server OAuth session.

Fixes #103117
2026-09-24 05:33:01 -05:00
Ayush Nangia
2fabc78292 test(projects): adapt NFC regression to the current session fixture
(cherry picked from commit 380030f6f8f61dac4d181577f0ae7d9d78dfd62b)
2026-09-24 05:31:58 -05:00
Ayush Nangia
284765d3d9 fix(projects): NFC-normalize path identity so accented folders keep their sessions (#65014)
(cherry picked from commit 762e7bd49bbb0b82de41d574ad74ed39fe947507)
2026-09-24 05:31:58 -05:00
hermes-seaeye[bot]
76c5bdcc9d fmt(js): npm run fix on merge (#121304)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-24 09:43:10 +00:00
brooklyn!
0397efef87 fix(desktop): every project session is reachable from the sidebar
An entered lane stopped at 5 chats behind a hover-only "…" that added 5
at a time, and an expanded overview project or entered Home mounted every
loaded row at once. Lanes, expanded projects and Home now page with a
labeled "Show N more in <lane>" row (50 per step), reusing the overview's
show-all row. The collapsed preview and tree request are unchanged.

Co-authored-by: cherrish333013 <139594807+cherrish333013@users.noreply.github.com>
Co-authored-by: George Shalhoub <george@georgeshalhoub.com>
2026-09-24 04:32:58 -05:00
brooklyn!
03add1fe7a fix(desktop): keep root-only project chats out of Home
A session row with an empty cwd but a persisted git_repo_root fell to Home:
_project_for_session bailed on the empty cwd before matching the repo root,
and _place_session refused to place it, so the backend owner map made Home
the chat's single owner while the renderer (root-aware) still labeled it with
its project. Match and place on cwd, else git_repo_root; a row is homeless
only when it has neither, matching the renderer's isDetachedSession.

Entered Home's live overlay also upserted every briefly-detached live row
without consulting the authoritative owner map, so a named-project chat whose
session.info landed with an empty cwd listed in Home as well. overlayHomeLane
now takes the owner map from overlayLiveLanes and skips rows (by id or
lineage root) owned by a named project.

Refs #77591 (symptom D, entered-project refetch lag, is not addressed).

Co-authored-by: networthexplained <300128320+networthexplained@users.noreply.github.com>
2026-09-24 04:31:47 -05:00
brooklyn!
94dcb170fe fix(desktop): isolate primary and pooled backend spawns from launch-profile secrets
Both local `hermes serve` spawn sites spread process.env into the child.
Route them through profileBackendParentEnv so a pooled profile backend, and
a primary pinned to (or sticky on) a non-launch profile, only get the
credentials their own profile scope provides (#68367).

Co-authored-by: Yash Jain <23dec512@lnmiit.ac.in>
2026-09-24 04:29:19 -05:00
brooklyn!
d17f5191cc fix(desktop): profile backend env drops the launch profile's dotenv names
`hermes desktop` loads its launch profile's .env/.op.env into os.environ
before exec'ing Electron, so every Desktop-spawned backend inherited that
profile's credentials. A named profile with no TLON_* (or its own .op.env)
still came up with the default profile's values ahead of its own dotenv.

profileBackendParentEnv() returns the parent env for a `hermes serve`
child of a given profile: unchanged when the child is the launch profile,
otherwise minus every name the launch profile's .env/.op.env declares
(OS names like PATH/HOME excepted). Mirrors _profile_action_environment on
the dashboard side. Windows folds env names and home paths.

Co-authored-by: Yash Jain <23dec512@lnmiit.ac.in>
2026-09-24 04:29:19 -05:00
brooklyn!
7ba3a71611 fix(desktop): closing a tool tab in the chat's zone fronts the chat 2026-09-24 04:27:38 -05:00
brooklyn!
ecacf3d0c9 fix(desktop): declare the branch methods in the typed gateway contract
Rebased onto main, where params are validated against tui_gateway/contracts
and unknown keys are rejected: the copy_parent_history / omit_messages flags
on session.create and session.branch now answered 4000. Keep both methods'
wire shape unchanged and give the two new methods their own contracts
(session.branch_stored, session.branch_whole) with messages_omitted results;
the handlers take the behaviour as keyword arguments, not wire flags.
2026-09-24 03:57:08 -05:00
Benjamin Brumbaugh
d95a6bc910 fix(desktop): preserve message-level branch responses 2026-09-24 03:57:08 -05:00
Benjamin Brumbaugh
1c7ec93aea fix(desktop): preserve branch compatibility with older gateways 2026-09-24 03:57:08 -05:00
Benjamin Brumbaugh
44812b5e90 fix(desktop): branch long sessions without renderer transcript load 2026-09-24 03:57:08 -05:00
teknium1
c2645ef14c fix(desktop): SandboxedFrame takes an explicit prop allowlist, not iframe attrs
Review finding (MAJOR) on #120927: the props type extended
`ComponentProps<'iframe'>` and the component spread them onto the element,
so a plugin could pass `allow="camera; microphone"` — the electron
`setPermissionRequestHandler`/`setPermissionCheckHandler` grant media
capture without looking at the requesting frame's origin, so that hands a
third-party site the app's mic/camera — plus `srcDoc` (replaces the `src`
contract with caller markup), `name`, `allowFullScreen`, `csp`,
`credentialless`. Props are now an explicit interface (className, style,
onLoad, onError, ref, sandbox, src, title) and nothing is spread, so none
of those reach the DOM even through a cast.

minor: `src` is scheme-checked — only http(s)/data render, anything else
(file:, blob:, javascript:, relative) renders nothing with a console.warn,
matching what the docs already promised.

minor: the SDK surface exports only `SandboxedFrame` + `SandboxedFrameProps`;
`sanitizeFrameSandbox`/`SANDBOXED_FRAME_DEFAULT_SANDBOX` stay module-internal.

Tests: the existing posture test now also asserts allow/srcdoc/name/
allowfullscreen never land on the element; one new test covers the scheme
refusal. Both were red on 17d1b046.
2026-09-24 01:53:34 -07:00
teknium1
8636d6a57b style(desktop): blank lines before statements in sandboxed-frame test 2026-09-24 01:53:34 -07:00
teknium1
a48557baff feat(desktop): trim SandboxedFrame tests to invariants; document the allowlist contract
Nine change-detector tests become four invariants (one per behaviour, two
behaviours per module): every realm-escaping / unknown / mixed-case token is
dropped and an emptied set falls back to the default posture; allowlisted
tokens survive deduped; the rendered frame carries the posture; props cannot
re-open it.

Docs: TS signature, the exact allowlist and the strip list the ruling names
(allow-same-origin, allow-top-navigation*, allow-popups*, allow-modals,
allow-storage-access-by-user-activation), teardown, and the rss-reader
(#115972) migration off its stubbed /preview → openExternal chain.
2026-09-24 01:53:34 -07:00
tobenwarrior
d81db86d61 feat(desktop): sandboxed embed primitive for plugins
A reader-style plugin embeds external content by mounting a raw Electron
<webview> on the app's persist:hermes-preview partition — sharing the app's
cookies and storage. Give plugin authors the app's own guest-content posture
instead.

SandboxedFrame renders a sandboxed iframe: opaque origin, allow-scripts by
default, no-referrer, lazy. Realm-escaping tokens (allow-same-origin,
top-navigation, popups, modals, storage-access) are stripped even when a
caller asks for them — a frame with no sandbox attribute is fully
privileged, so an empty result falls back to the default posture.

Wishlist item 9 of #116305; unblocks rss-reader (#115972).
2026-09-24 01:53:34 -07:00
brooklyn!
f003387212 fix(desktop): pet overlay stays clickable on Linux and survives a replaced close
- forward:true is macOS/Windows only; on Linux the spawn-time click-through
  left the pet unclickable, so it stays a solid window there (same call as
  the X11 HUD) and renderer ignore requests are vetoed.
- A stale overlay's 'closed' handler popped the pet back in over its
  replacement; it now only acts while it is still the current window.
- Drop the re-home click-through re-assert: setBounds keeps the ignore
  state, and forcing it desynced the renderer's hover dedupe.
2026-09-24 03:52:03 -05:00
brooklyn!
83ee4bcba1 fix(desktop): branches of a collapsed stale tip nest under the live tip 2026-09-24 03:52:03 -05:00
brooklyn!
edecebf69d fix(bootstrap): show the update child's exit-2 refusal instead of "still running"
Every exit 2 from `hermes update` was reported as "Hermes is still
running. Close all Hermes windows", including when the holder was another
live update. The child prints the specific refusal as a ✗ block right
before exiting; keep a bounded stdout tail and show that block, falling
back to the generic text only when none was captured.

Co-authored-by: RelaxJonh <92573950+RelaxJonh@users.noreply.github.com>
2026-09-24 03:49:06 -05:00
brooklyn!
620d38838d fix(desktop): show retry in place of "No sessions yet" when the first read fails
The recents empty state renders the existing SidebarLoadErrorState, the same one the project drill-in uses, when $sessionsLoadError is set. Retry re-runs refreshSessions through a new onRetrySessions sidebar action. Projects load separately, so the flag does not replace the project empty state.

Refs #67600
2026-09-24 03:43:21 -05:00
brooklyn!
72bc2f6a4f fix(desktop): flag a failed cold-start sidebar read instead of reporting an empty list
A first read that throws, or that reports a scan failure and returns no rows, now sets $sessionsLoadError. A later refresh recomputes it, and a gateway switch clears it. A failure over rows already on screen never sets it. Errors from a store latched corrupt are left out, since that store already has its own notice and a retry can't repair it.

Refs #67600
2026-09-24 03:43:21 -05:00
brooklyn!
efafed5baf fix(desktop): Kanban attachment download through the one gated resolver
Fold the Kanban attachment download onto the existing gateway file-save
resolver (lib/media.ts downloadGatewayMediaFile / captureGatewayFileDownload)
instead of the source PR's second resolver (api/file-download.ts): one auth
shape, one owner-scope contract, for every Desktop gateway-file save.

- lib/media.ts: downloadGatewayMediaFile now accepts an explicit owner scope
  ({ connectionId, profile }) so a capture snapshot (Kanban) and the ambient
  $connection path (artifacts, chat previews) share one function. Adds
  downloadGatewayFileWithFeedback (the toast wrapper) and
  captureGatewayFileDownload (snapshots capabilityScoped() at read time).
- store/file-actions.ts: downloadRemoteFile is now a thin call into
  downloadGatewayFileWithFeedback -- the same fileMenu.downloadSaved /
  downloadFailed toasts the Files panel already used; cancel stays silent.
- plugins/kanban/drawer.tsx: rebased AttachmentDownload/AttachmentsSection
  onto main's Dialog-based drawer (aside "Attachments" section), using the
  app's boxless text-button treatment (size="inline" variant="text") to
  match the drawer's other inline actions, with a Tip for a long filename.
- sdk/index.ts: keeps captureGatewayFileDownload as the plugin SDK export
  (now sourced from lib/media, not a second module); the plugin docs keep
  it as a void-returning, non-throwing capture (toasts already fire inside).
- types.ts: keeps KanbanAttachment.stored_path.
- Deletes api/file-download.ts and its resolver; drawer/file-actions tests
  adapted for the new call shape plus a local-mode ownership case.

Root cause: the Kanban drawer only ever rendered the attachment filename
with no action, so on macOS (and everywhere else) there was no way to fetch
the stored file at all -- the backend already returns stored_path
(plugins/kanban/dashboard/plugin_api.py) but nothing in the renderer used it.

Tests: apps/desktop `npx vitest run --project ui src/plugins/kanban
src/lib/media src/api src/store/file-actions` (112 passed); the new drawer /
media-file-download / file-actions cases fail on main first (confirmed
against a scratch main worktree) and pass here. `npm run typecheck` clean.

Limits: attachments with no stored_path (older backend rows) keep the
control disabled rather than guessing a workspace path, matching the source
PR's compatibility stance.

Fixes: https://github.com/NousResearch/hermes-agent/issues/85672
Supersedes: https://github.com/NousResearch/hermes-agent/pull/107370
Supersedes: https://github.com/NousResearch/hermes-agent/pull/110161
Supersedes: https://github.com/NousResearch/hermes-agent/pull/87727

Co-authored-by: Johan Roest <229638764+jroest@users.noreply.github.com>
2026-09-24 03:42:42 -05:00
Johan Roest
d9a44a0ed4 fix(desktop): download Kanban attachments through authenticated gateway
(cherry picked from commit 1f9e64b0ed27f68885ad0a211914e7c10736c0ab)
2026-09-24 03:42:42 -05:00
teknium1
55c52f2902 fix(desktop): appearance extras use the pane boundary and render on the top-level page only
Review findings (minor) on #120912:

* `AppearanceExtraSlot` wrapped each contribution in `ContribBoundary
  variant="chip"`, so a page-level plugin card that threw collapsed into a
  bar-item chip meant for toolbar slots. Use the default `pane` variant —
  the canonical ErrorState with Retry, matching every other zone body.

* The slot was mounted unconditionally, so every deep-link subpage
  (`settings/appearance/<section>`, which shows exactly one built-in
  section) also grew the plugin cards. Gate it on `subpage === undefined`
  like the rest of the page's top-level-only chrome.

Tests: the boundary test now asserts the pane fallback (red on c322f692);
one new page-level test renders `AppearanceSettings` with and without a
subpage and asserts the extra mounts only on the top-level page (red on
c322f692). Docs updated to the new contract.
2026-09-24 01:34:59 -07:00
teknium1
84ac69e884 feat(desktop): keep ColorSwatches export as on main; document APPEARANCE_AREAS.extra contract
Drop the salvaged ColorSwatches re-export hunk: main already exports the
component from the SDK, and the plain-JS plugins this slot serves do not need
the props type, so the shared sdk/index.ts hunk stays a single added line.

Docs: TS signature, arbitration (every registration mounts in its own
boundary — no first-wins, plugins cannot suppress each other), teardown
(loader-owned disposer, nothing persisted) and the exact migration for
better-session-appearance (#115961) and hermes-appearance-hub (#116049).
2026-09-24 01:34:59 -07:00
tobenwarrior
8d71403ffa feat(desktop): appearance-settings slot and the app's swatch grid for plugins
A plugin adding appearance controls injects nodes into Settings → Appearance
and drives the app's widgets through React internals. Give it a seam.

APPEARANCE_AREAS.extra renders contributions at the end of the Appearance
page (own error boundary each, chip fallback), and ColorSwatches — the grid
the profile rail and project dialog already use — joins the public SDK
surface with its props type, so a plugin picks colours with the app's own
control and its own onChange (pair it with host.sessions.setColor for
session colours).

Wishlist item 7 of #116305; unblocks better-session-appearance (#115961).
2026-09-24 01:34:59 -07:00
teknium1
4cec3f85e7 fix(desktop): host.pluginDecisions hands out frozen copies, not the live store object
Review finding (MAJOR) on the typed bridge: `pluginDecisions.get()`,
`.value` and the `subscribe`/`listen` callback argument returned the
store's own object. `pluginActive()` reads that same reference and
`saveDecisions({...$pluginDecisions.get(), [id]: enabled})` spreads it,
so `host.pluginDecisions.get()['other'] = false` silently disabled
another plugin and the next toggle persisted it — the declined `set()`
by another door.

Every value the read-only view hands out is now `Object.freeze({...v})`;
assignment throws under strict mode and the store is untouched. The
existing read-only test gains the mutation assertion (red on c1b740ac).
Docs: frozen-copy contract spelled out; cheat-sheet signature for
`host.profiles.list(scope?: ProfileScope)` made explicit.
2026-09-24 01:25:11 -07:00
teknium1
488cd566ba feat(desktop): move the capabilities bridge to sdk/bridge.ts; pluginDecisions read-only
Own module for host.skills/toolsets/profiles/pluginDecisions so sdk/index.ts
carries only the import and the host keys (the file is a god-facade already
and every SDK lane adds a hunk to it).

Drop `host.pluginDecisions.set()` from the salvaged shape. A plugin flipping
another plugin's enable state is exactly the "plugins messing with each
other" the SDK ruling forbids, and `host` is a module singleton that cannot
tell which plugin is calling to restrict `set` to the caller's own id.
Toggling stays in the app's Capabilities → Plugins tab. `pluginDecisions` is
now a nanostore ReadableAtom view (get/subscribe/listen) with NO `set` at
runtime — a hand-built delegate, because both a type cast and `computed()`
still carry `.set` on the object.

Tests: one invariant per behaviour (api-module routing with the ProfileScope
forwarded; read-only live view). Both red on the contributor commit
(`subscribe is not a function`) and on origin/main (no host.skills).
2026-09-24 01:25:11 -07:00
tobenwarrior
bd55633248 feat(desktop): typed skills/toolsets/profiles bridge for plugins
Plugins configuring capabilities called window.hermesDesktop.api raw and
read/wrote the persisted pluginDecisions map directly. Give them the same
doors the Capabilities page uses.

host.skills.list/setEnabled, host.toolsets.list/setEnabled, and
host.profiles.list wrap the app's own api modules — same endpoints, same
profile scoping (a ProfileScope configures any profile without swapping the
app-wide active one) — and host.pluginDecisions exposes the decisions map
with set() going through the live toggle (deactivate/activate included),
never a raw storage write.

Wishlist item 6 of #116305; unblocks better-capabilities (#115960).
2026-09-24 01:25:11 -07:00
hermes-seaeye[bot]
3b8c77c428 fmt(js): npm run fix on merge (#121210)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-24 08:22:10 +00:00
teknium1
6b2c23ae42 fix(desktop): model-pill provider must return a string; non-strings fall through
Review finding (MAJOR) on #120919: useComposerModelPillLabel accepted any
truthy return with `if (label)`. ModelPill drops the value straight into JSX
with no error boundary, so a plugin returning an object/array/number threw
"Objects are not valid as a React child" and blanked the whole composer —
exactly the failure mode the hook's throw-swallowing was meant to prevent.
Only a non-empty, non-whitespace string is now a label; anything else
declines like null.

Review finding (minor): the two existing tests are tightened instead of
adding new ones. The compact-mode "providers skipped" assertion was
`queryByText(...)` on a chevron-only render and passed regardless; it is now
a spy that must not be called. The throw test now also covers the
non-string return (red on b559ac9c: React child error) and two non-null
providers: the first registered string wins and the later provider's spy is
never invoked.

Docs: arbitration section states the string-only contract and that
`reasoningEffort` is always a string ('' when none).
2026-09-24 01:12:58 -07:00
teknium1
eecabbe70b feat(desktop): memoise model-pill providers on primitives; cover compact mode
useComposerModelPillLabel took the whole ctx object but listed only its
fields as useMemo deps, which the hooks lint flags (missing dependency
'ctx'). Destructure the primitives at the boundary and rebuild the ctx
inside the memo: same behaviour, and the memo now honestly depends on
exactly what providers can observe, so a plugin's label() runs only when
the registry or the pill inputs change, never per keystroke.

Fold the compact-mode guarantee into the existing provider test (floating
composer renders only the chevron; provider text must not leak) and assert
the core label is actually restored after a declining provider, so the
"first non-null wins, else core label" rule is pinned end to end.
2026-09-24 01:12:58 -07:00
tobenwarrior
2b4a5a0924 feat(desktop): model-pill label providers for plugins
The compact-reasoning-label plugin rewrites the model pill's textContent
through a MutationObserver to show a compact reasoning label. Give it the
sanctioned seam instead.

COMPOSER_AREAS.modelPill takes data contributions with a label(ctx) resolver
({ model, reasoningEffort, compact }); the first non-null label wins and a
declining (or throwing) provider leaves the core label. The pill keeps its
chrome, pin dot, and menu — only the text changes.

Wishlist item 5 of #116305; unblocks compact-reasoning-label (#115962).
2026-09-24 01:12:58 -07:00
brooklyn!
c882636db2 fix(desktop): raw-URL autolinker steps over markdown links (#49822)
RAW_URL_RE admits `]` and `(`, and the only guard looked at the two
characters before each match. A URL in link text (`[https://x](https://x)`)
matched through `](https://x)` and was wrapped as one autolink, so the href
became `https://x%5D(https://x)` (#49822, #85234).

Split each prose segment on the markdown that owns its URLs (inline links
and images, full/collapsed references, definitions, and a label or target
still streaming in) and autolink only the gaps. The trailing peel now also
drops a stray `]` and the punctuation a peeled closer exposes, while paired
closers inside the URL (`/wiki/Foo_(bar)`, `http://[::1]/`) stay.

Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
2026-09-24 02:59:54 -05:00
harjoth
ef9a5f9889 fix(desktop): preserve matched URL parentheses
(cherry picked from commit 6e126d8575859f9ca7a6d20240e2e2ba61c74b7b)
2026-09-24 02:59:54 -05:00
harjoth
d51727f9ba refactor(desktop): single-pass unbalanced-paren trim in raw-URL autolinker
trimUnbalancedTrailingParens recounted every paren on each stripped
character (O(n·k)). Count opens/closes once, then peel trailing closers
off the end — behaviorally identical, since the loop only ever removes
trailing `)` so `opens` never changes. Add an intent comment documenting
the balance heuristic to match the file's style.

No behavior change: markdown-text.test.ts 23/23 green, typecheck + eslint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4c80b4e5d041c426ee26e6fa1715a380b4e42301)
2026-09-24 02:59:54 -05:00
harjoth
826e40c1b3 fix(desktop): trim wrapper parens from raw URL autolinks
(cherry picked from commit f79f306b7d0a61d884b203583fde3e161f36003a)
2026-09-24 02:59:54 -05:00
teknium1
2553b8660b fix(desktop): sidebarNav.prefs cannot hide the Plugins row; document the real order rule and namespaced ids
Review findings on #120922.
- Hiding `capabilities` removed the row that hosts the Plugins tab, the
  user's only path to a plugin's own off-switch — the design law forbids a
  plugin locking the user out of disabling it. `NEVER_HIDDEN` keeps that
  row; it can still be re-ordered.
- Docs said "first-registered order wins", but `registry.getArea` sorts by
  `Contribution.order` then insertion, so a later plugin with `order: -1`
  pre-empts. The registry exposes no insertion order, so the docs (and the
  SDK/store comments) now state the real rule: lowest `order`, then
  registration. A registry-backed test pins it.
- Docs said a contributed row's id equals its `SIDEBAR_NAV_AREA` id, but
  `createPluginContext.scope` namespaces it to `${pluginId}:${id}`; the
  docs now give the namespaced form to use in `hide`/`order`.
2026-09-24 00:59:20 -07:00
teknium1
7fba0e6e73 feat(desktop): sidebar nav prefs become a sidebarNav.prefs contribution
Replace the persisted `host.sidebar.hide()/setOrder()` store from #116409
with a registry contribution area (`SIDEBAR_NAV_PREFS_AREA`), keeping the
contributor's pure merge function (now `applySidebarNavPrefs`) fed from
`useContributions()` instead of two persisted atoms.

Why: `host` is a module singleton that cannot attribute a write to the
plugin that made it, so a persisted preference outlives the plugin (a
hidden row with nothing left to restore it) and two plugins overwrite each
other's order. A contribution is attributed, merged with a stated rule and
dropped by the loader's per-plugin disposer on disable/reload, so the rows
come back on their own.

Arbitration: hidden = union of every contribution's `hide` (hide beats
order); order = first-registered contribution wins, later ones place only
ids not yet placed; unknown ids inert; unnamed rows keep default relative
order after the named ones. The user's choices persist in the plugin's own
`ctx.storage`; it re-contributes them (same id replaces).

No `host.sidebar` key, no `hermes.desktop.sidebarNav*` localStorage keys.
Tests: one pure arbitration invariant + the sidebar restoring the row on
dispose (red on origin/main).
2026-09-24 00:59:20 -07:00
tobenwarrior
632a5cd1c8 feat(desktop): sidebar nav visibility and order for plugins
A sidebar-manager plugin had no SDK door for moving or hiding nav rows —
today it hides core rows with CSS display:none and re-parents React-owned
children. Give it a preference store core reads at render.

host.sidebar.hide(navId, hidden?) and host.sidebar.setOrder(ids) write the
persisted nav-preference store; the sidebar applies them through one pure
function (orderSidebarNav) so the semantics are testable: hidden ids drop,
named ids come first in that order, unnamed rows keep their default relative
order after them, unknown ids are inert. The preference never mutates the
default list — removing the plugin leaves every row where it was.

Wishlist item 4 of #116305; unblocks sidebar-manager (#115973).
2026-09-24 00:59:20 -07:00
brooklyn!
72b8502f42 fix(desktop): open the Nous sign-in dialog from Billing's logged-out notice
The logged-out notice only opened the portal in a browser, which writes no
credential, so Billing never left the logged-out state (#87792). Route it to
the shared sign-in dialog and drop the /portal hint; refusal notices keep
their portal link under a dedicated openPortal label.

Co-authored-by: George Jieh <99384826+georgejieh@users.noreply.github.com>
2026-09-24 02:57:01 -05:00
brooklyn!
bbba4d4f77 fix(desktop): group messaging platforms by gateway/profile owner in all-profiles view
With "Show all profiles" on, each messaging platform section listed rows by
source only, so a Telegram thread gave no hint which profile it belonged to.

Rework the cherry-picked idea onto the current owner model:

- Extract buildGatewaySessionGroups() from useGatewaySessionGroups (now a thin
  useMemo wrapper) so recents and messaging share one resolver keyed by
  [connectionId, profile]. Drop the profile-only profile-groups.ts.
- Under profile grouping, group each platform's rows after the visible cap, so
  the platform's load-more count and footer stay coherent. Render through
  GatewayProfileGroups in a new embedded mode: flat owner groups, no nested
  paging, no new-session button, header slot or profile-wide totals.
- scopeGatewaySessionGroups() namespaces group ids per platform so collapse,
  alias and order preferences never collide with the recents groups, and keeps
  the gateway in the label only when a platform mixes gateways.
- Under any other grouping, messaging rows carry profile tags like the pinned
  and search sections.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
2026-09-24 02:46:32 -05:00
webtecnica
3fa2123c88 fix(desktop): group messaging sidebar sections by profile when showing all profiles (#87715)
Messaging platform sections only grouped by profile while the whole
sidebar was in profile-grouped view; individual platforms ignored it,
so rows from different profiles were mixed within one platform section.
Extract buildProfileGroups() (shared helper: same keying/colors/order as
the recents profile groups, default floats first) and apply it per
platform section when showAllProfiles + grouping=profile. 5 vitest tests.

(cherry picked from commit 6941d7cc5f7c5251f068f97856e1a8c741e80d05)
2026-09-24 02:46:32 -05:00