Widen the composer-side IME fixes to the inline edit composer: the same
missed-compositionend wedge and post-compositionend keyCode 229 Enter
apply to its handleKeyDown path.
Chinese/Japanese/Korean IMEs emit keydown events during composition that
carry preedit keystrokes and the commit keypress (Enter/Space/Shift for
candidate selection). Treating them as combos fires unrelated keybinds —
e.g. typing 你 with a CJK IME could dispatch session.new and silently
open a new session.
Guard comboFromEvent():
- Bail out entirely while composing (event.isComposing or key === 'Process')
- Ignore keydowns whose event.key is a bare modifier name but whose code
is a regular key — legacy IMEs that synthesize keystrokes (Q9 2002 sends
key="Control" with code="KeyW") would otherwise canonicalize into
phantom combos like mod+w that close the active tab.
Tested with Q9 (九方) legacy IME on Windows.
A missed compositionend (focus jump, input-source switch, programmatic DOM
swap mid-preedit) left composingRef stuck true, and the stuck flag silently
swallowed every Enter in handleEditorKeyDown and every Send-button submit via
the form onSubmit guard — no error, no RPC, until the composer remounted. For
CJK IME users (where even ASCII typing runs through composition) this read as
"Enter has no effect; messages cannot be sent", degrading composer instance
by composer instance.
Recover in two places, both grounded in invariants Chromium guarantees:
- keydown: every keydown during a genuine composition carries
isComposing=true, so when the native flag says we're not composing, clear
the stale ref before the guard reads it.
- blur: a composition never survives focus loss, so clear the flag
unconditionally — this is what unblocks the Send button path, which has no
native composition flag to consult.
The genuine-IME protection (#37483 class) is untouched: Enter with
isComposing=true is still swallowed.
Fixes#44135
macOS Chinese IME (and some 3rd-party Windows IMEs) emit Enter with
keyCode 229 (legacy VK_PROCESSKEY) after compositionend, while
isComposing is already false. The existing guard only checked
isComposing and the composingRef, so this Enter slipped through and
submitted the message before the committed text was fully in the DOM.
Add an explicit keyCode 229 check in handleEditorKeyDown. keyCode is
deprecated, but it is the only reliable signal for this IME commit
Enter on Chromium-based browsers. Includes a dom-repro test that
simulates the macOS IME sequence.
Fixes: "中文混英文按 Enter 直接上屏"
Check event.nativeEvent.isComposing in Enter-to-submit branches so CJK (Japanese, Chinese, Korean) and other compositional input methods commit the candidate instead of firing the send handler.
Applied to all five sites in the desktop renderer that currently handle Enter with no composition guard:
- chat composer main submit and trigger popover (apps/desktop/src/app/chat/composer/index.tsx)
- message edit composer submit and trigger popover (apps/desktop/src/components/assistant-ui/thread.tsx)
- onboarding API key and auth code inputs (apps/desktop/src/components/desktop-onboarding-overlay.tsx)
- session rename input (apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx)
Fixes#37483
A session deleted in the sidebar disappeared optimistically but flashed
back when any list fetch raced the in-flight DELETE RPC — the backend
page still carried the doomed row until the transaction committed
(#50928, reproduced with 'Load more' and auto-refresh). The optimistic
tombstone ($removedSessionIds) was only honored by the recents slice of
refreshSessions; the messaging slice, the per-platform pager, and
refreshMessagingSessions ingested backend pages unfiltered.
Extract the tombstone filter into dropTombstoned() and apply it at every
session-list ingestion point. Tombstones only exist while a delete or
archive is in flight (they self-clear on confirmation and are removed
immediately on failure), so non-destructive refresh paths are untouched.
Fixes#50928
Deleting a session in the desktop app fired instantly on click — the CLI
path (hermes sessions delete) asks y/N by default, so one misclick (Archive
and Delete sit right next to each other) permanently destroyed a conversation
with no dialog and no undo (#61470).
Route every delete entry point (sidebar rows, tab menus, the chat header,
context menus — all share useSessionActions) through a shared
DeleteSessionDialog built on ConfirmDialog. ConfirmDialog gains an
onOpenAutoFocus prop so dialogs with no input keep focus off the close
button (a11y).
Tests: menu delete now asks; cancel keeps the session; Enter confirms;
Escape cancels; delete item disabled without onDelete; the same guard
applies via SessionContextMenu.
Config settings auto-save on a 550ms debounce with no undo. The
'Enabled Toolsets' list is rendered by the generic ConfigField with no
destructive-change guard, so a stray select-all + Backspace (or any edit
that empties the list) is persisted the moment Settings closes —
silently disabling memory, terminal, web search, delegation, and most
tools. Recovery required CLI intervention.
Guard the one destructive transition: when the enabled-toolsets list
goes from non-empty to empty, window.confirm() before applying it (the
same pattern env-var removal already uses in toolset-config-panel.tsx).
Every other edit passes through untouched.
The decision is a pure helper (clearsEnabledToolsets) so it is unit
tested directly rather than through a full settings render. New i18n key
toolsetsWipeConfirm added to en + zh; partial locales inherit the
English string via defineLocale fallback.
Fixes#73319
With 25+ sessions the recents list virtualizes into its own nested
scroller inside the sidebar's scroll container. Both carried
overscroll-contain, so once the inner scroller hit a scroll boundary the
wheel gesture was consumed instead of chaining to the outer sidebar
scroller — read as a mid-list wheel dead-zone while scrollbar drag kept
working. Drop the containment on the inner scroller only; the outer
sidebar scroller keeps overscroll-contain so the gesture still never
escapes the sidebar.
Fixes#84964
Non-repo explicit projects (plain folders) get a main-checkout lane whose
label is the folder basename, not a branch. Clicking "+" (new session) on
such a lane calls switchBranchInRepo -> switchBranch, which sanitizes the
basename to "" and throws "Branch name is required.", aborting the
session creation. Short-circuit switchBranch for roots that are not git
work trees so callers proceed with a plain session.
Fixes#83028
When a session's cwd moves from the main checkout to a newly created
worktree, overlayRepoLanes places it into the matching worktree lane but
never removed the stale entry from the main lane. The session appeared
under both groups until the user left and re-entered the project view.
Add a cross-lane eviction loop that removes the session from all other
lanes before inserting it into the target lane.
Live overlay always placed sessions under `::branch::main`, while the
backend non-git heuristic keys the lane by folder path (label =
basename). Overlay missed that lane by id/label and forked a phantom
`main` group with the same sessions.
Match existing path-keyed isMain lanes before creating a branch-style
main lane. Covers the codex-research-guardian-style drill-in duplicate.
pet.info accepts knownRevision; when it matches the active sheet's
revision the multi-MB spritesheetBase64 is elided and
spritesheetUnchanged=true is returned. The desktop floating pet passes
the revision it already holds and keeps its cached bytes, so backstop
refreshes no longer resend ~3.2MB frames over the WS (write-loop stalls,
disconnect storms). Legacy callers omitting knownRevision get the full
payload unchanged.
Event-capable backends no longer polled pet.info, so a cold-start
fail-open enabled:false left the mascot hidden until Settings re-seeded
the store. Keep a slow backstop and short startup retries.
Petdex spritesheets are 192x208px illustration frames, not pixel art.
The desktop canvas drew them with imageSmoothingEnabled=false
(nearest-neighbour), so zoomed pets looked blocky. Enable bicubic
smoothing so scaled frames stay clean at any zoom.
High-DPI backing-store sizing is addressed separately in #83276.
DPR-sized canvas backing store separated from CSS footprint, tracking
zoom/display changes live. Rendering-fix subset of PR #75307; the
overlay-placement feature portion is out of scope here.
Covers the devicePixelRatio half of #83216.
All three artifact timestamp sources (message.timestamp,
session.last_active, session.started_at) are epoch SECONDS — the
transcript reader and session-date-groups both multiply by 1000 — but
the collector passed them straight to new Date() (ms), so every
artifact rendered as 1970-01-21. Normalize seconds to ms once at
collection; the Date.now() fallback stays ms.
Local file artifacts (e.g. D:\ComfyUI\output\*.png) fell through to
mediaExternalUrl() which yields a file:// URL the renderer cannot
load. Route through the desktop fs bridge whenever it exists —
readDesktopFileDataUrl already dispatches remote REST vs local
Electron internally (#83380).
Require explicit provenance for tool-result artifacts while preserving assistant links, MEDIA deliveries, generated outputs, file mutations, and browser screenshots. Normalize persisted Unix-second timestamps at collection time and retain millisecond fallbacks.
Consolidates current-main-compatible work from #41156 and #48577.
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: tt-a1i <53142663+tt-a1i@users.noreply.github.com>
The Artifacts view reads message.timestamp, session.last_active, and
session.started_at from the SQLite database, which stores all timestamps
as Unix epoch seconds (REAL). These values were passed directly to
JavaScript's Date() constructor, which expects milliseconds — causing
every artifact timestamp to display as January 1970 dates.
Fix by multiplying the database value by 1000 to convert seconds to
milliseconds at the storage point, using nullish coalescing (??) instead
of logical OR (||) so that valid zero timestamps are not skipped.
Review fixes from #86679 comments (trevorgordon981, helix4u, kshitijk4poor):
- Edit inheritance: mergeConnectionInput preserves fields the editor does
not carry (cloud org, ssh remoteHermesPath/remoteProfile) so a rename no
longer wipes them. When the payload carries an ssh host string, stored
user/port are NOT inherited — the composite host field is authoritative,
fixing the stale user/port resurrection on edit.
- Token hygiene: tokens only persist on token-auth remotes; switching an
entry to oauth (or cloud) clears the stale envelope.
- Plain-text opt-in: the panel now surfaces the same consent dialog as
Settings -> Gateway on keyring-less machines (registry list exposes
secureTokenStorage; save retries with allowPlainTextToken after consent).
- Registry test isolation: hermes:connections:test builds the probe directly
from the registry entry instead of coercing against v1 connection.json —
no more inheriting the v1 global token for a different host, and the local
entry now probes the app-managed backend (never v1 remote/ssh state, so
the test button can no longer trigger a v1 file write).
- 'local' id reserved at the validation boundary: a crafted IPC payload can
no longer replace the local entry via upsert.
- Cloud creation hidden in the editor (a dialable cloud entry comes from the
Cloud sign-in/discovery flow); migrated cloud entries stay editable.
- First-run migration write is guarded: a failed write keeps the migrated
registry in memory instead of hard-failing every connections IPC call.
- uniqueLabel(): single label-dedup helper — counts up instead of "X 2 2",
clamps 253-char migrated URL-host labels under LABEL_MAX; used by
normalizeRegistry and both migration paths.
- UI copy: staged-rollout note replaces the "side by side" claim; test
failure toast leads with the failure wording; dropped unused i18n keys.
Tests: +9 pure-module cases (reserved id, token-drop rules, merge
inheritance, ssh host precedence, uniqueLabel); electron+settings suites
1355 passed.
First slice of multi-source agent support: the desktop can now persist ANY
number of named backends (local runtime, remote gateways, Hermes Cloud
instances, SSH hosts) side by side instead of one global connection plus
per-profile overrides.
- electron/connection-registry.ts: pure v2 registry module — required
case-insensitively-unique labels (device names), @name-device handle rule
for duplicate profile names across sources (agentHandle), defensive
normalizeRegistry for corrupt files, one-time v1→v2 migration that imports
the global block + per-profile overrides (deduped by URL/host) and leaves
connection.json untouched for older builds.
- main.ts: connections.json storage beside connection.json (same secret
posture: safeStorage-encrypted tokens, 0600, tighten-before-parse, mtime
cache) + hermes:connections:* IPC (list/save/remove/set-primary/test).
Test maps registry entries onto the existing testDesktopConnectionConfig
probe stack — no new probe code.
- Settings → Connections: manage the registry (add/edit/remove/test/make
primary) with forced naming; local entry is non-removable; removing the
primary retargets to local. en + zh locales.
Storage-level only by design: routing/pool generalization to composite
(connection, profile) keys, the multi-source roster, plugin SDK surface, and
fan-out updates land as follow-up PRs.
The composer's "Use skill: X" pill only checked the draft text and the
workspace-name collision - it happily re-offered a skill the session had
already loaded (skill_view), edited (skill_manage), or that the user had
invoked via its /name command. Clicking it would re-inject the full
SKILL.md into a context that already carries it.
The draft provider now scans the session transcript (per-runtime
$sessionStates mirror, $messages for the active session) for
skill_view/skill_manage tool calls naming the skill - exact or qualified
(category/name, plugin:name), from parsed args or hydrated argsText -
and for user turns starting with the skill's slash command, and stands
down on a hit. The scan only runs when the draft actually matched a
skill, so ordinary typing pays nothing.
Three fixes for generated/displayed images in the desktop chat:
- Shell fallback context menu no longer swallows right-clicks on images:
the guard now yields to Electron's native image menu (Copy Image, Copy
Image Address, Save Image As...) for img/picture/video/canvas targets,
matching the existing editable/selection carve-outs.
- Save Image As / download button: generated-image URLs (fal.media etc.)
end in an extensionless content hash, so saves produced an unopenable
"All Files" blob. The main-process save dialog and the renderer anchor
fallback both now append a MIME-derived extension, add image type
filters, and default to the user's Downloads directory instead of the
process cwd (win-unpacked on packaged Windows installs).
- New will-download handler routes any Chromium-initiated download
through the same Downloads-dir + guaranteed-extension policy.
Validation: new unit tests for the filename derivation (6 passing);
npm run check:lint green (tsc x3 + eslint, 0 errors).
The update hand-off spawned the detached updater, called unref(), and
quit unconditionally after the 2.5s dwell. Node reports exec failures
(ENOENT/EACCES) asynchronously via the child 'error' event, and a
short-lived updater can die inside that window — in both cases the app
vanished with no updater, no relaunch, and no evidence (the reported
macOS incident, and the posix.sh early-death reports on the same
thread).
Add observeUpdaterHandoff(): watch the just-spawned child for 'error'
and early 'exit' during the existing dwell (no added latency — the
dwell doubles as the settle window). Clean exit 0 inside the window
stays a success (the Windows `cmd start` wrapper exits immediately by
design); a spawn error, non-zero exit, or signal death is a failed
hand-off. On failure:
- applyUpdates (Windows hand-off): don't quit — restart the backend and
surface a structured error to the UI.
- applyUpdatesPosixHandoff (mac/linux): don't quit — surface the error.
- handOffWindowsBootstrapRecovery: return false so the caller falls
through to its next recovery path instead of quitting into nothing.
The pre-written update marker names the dead child pid, so
readLiveUpdateMarker self-heals it; no marker cleanup needed. Children
without an event interface settle ok after the window, keeping the
observation a best-effort hardening rather than a new way to wedge an
update.
Covered by 7 new unit tests (spawn-error, non-zero exit, signal death,
clean exit 0 wrapper, survival, double-settle, event-less child).
Closes#66753
The desktop window opened blank white on a fresh install: React threw
"Minified React error #527" before the first paint, from the
`vendor-react-<hash>.js` chunk.
`apps/desktop` pins react and react-dom to the same exact version, but
`vite.config.ts` aliased both to a hardcoded `../../node_modules/<pkg>` —
straight into the monorepo root, where npm is free to hoist a different
react. `@streamdown/math` is a root dependency whose react peer is
`^18.0.0 || ^19.0.0` and which declares no react-dom peer, so npm hoists
the newest react (19.2.8) to the root while react-dom stays at the
version hoisted from the workspaces (19.2.7). react-dom's own peer is
`react: ^19.2.7`, which 19.2.8 satisfies, so the install reports success
and nothing warns. The bundle then shipped react 19.2.8 with react-dom
19.2.7 and React refused to run.
`npm ci` masks this because the lockfile pins the root react to 19.2.7,
which is why CI is green. The recurrence engine is
`_run_npm_install_deterministic()`: when `npm ci` fails it falls back to
`npm install --no-save`, which re-resolves the whole tree and never
records the result — so the split comes back on the next update and
leaves no trace.
Fix the resolution rather than the hoist. The aliases now resolve both
packages from the desktop workspace itself, where npm guarantees the
declared versions are reachable (it nests a copy under the workspace
exactly when the hoisted one differs), so the pair can only ever match.
Pinning react at the npm layer instead was rejected: every manifest-level
pin tried (root dependency, root `overrides`, a scoped override on
`@streamdown/math`) breaks a fresh install with
`ERESOLVE ... peer ink-text-input@"6.0.0" from @hermes/ink@0.0.1`.
Two guards keep it from silently returning:
- `assert-root-install.mjs` (the existing preflight of `build`,
`dev:renderer` and `preview`) now fails the build when the resolved
react and react-dom versions differ, so a split surfaces as an
actionable error instead of a white window.
- A `tests-js` contract test asserts every workspace pins the two to the
same exact version, and that the desktop bundler no longer points at a
hardcoded `node_modules` path.
Verified on a synthetic split tree (root react 19.2.8 / react-dom 19.2.7,
workspace react 19.2.7): the old aliases resolve 19.2.8 + 19.2.7, the new
ones resolve 19.2.7 + 19.2.7, and the preflight exits 1 when the
workspace itself resolves the mismatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sibling site of the idle-resume rule from the stale-fold fix: the
assistant-tail append exit (user row persisted, no projection row) still
carried the journal's streamId onto a not-running resume, which kept the
journal entry alive (persistInFlightTurnState only clears when streamId is
null) and re-folded the same tail on every open. Apply the same
keepPending gate and pin it with a regression assertion.
Refs #85308
The inflight-turn journal can outlive the turn it recorded (reclaim,
reconnect or restart races skip the settle that clears it). On session
resume the fold then re-appends journaled assistant rows to a transcript
that already holds the committed replies, so the conversation ends with
duplicate answers in scrambled order. The fold also carried the stale
entry's streamId onto the resumed state on an idle resume, which kept the
journal entry alive (persistInFlightTurnState only clears when streamId is
null) and re-folded the same tail on every open.
Detect text-level staleness before the append path: when every recoverable
journaled assistant row already exists as committed text in the base
transcript, treat the entry as caught up and clear it. Only keep a stream
target when the resumed session is genuinely running (keepPending), so an
idle resume self-heals instead of re-folding.
The inflight journal regression test always spied on Storage.prototype,
but Node 26's jsdom setup can provide a plain in-memory localStorage fallback.
That left the test unable to observe the setItem call in CI even though the
per-session journal write was correct.
Select the native window.Storage prototype when available and otherwise spy
on the active localStorage object, preserving the assertion across both
storage implementations.
Refs #82832
The desktop journal synchronously read, parsed, cloned, and rewrote one
aggregate localStorage value while streamed turns were repainting. Large
tool results and multi-session state could therefore block the renderer and
leave the app unresponsive, while the existing macOS diagnostic path lacked
a real native hide/restore regression check.
Store bounded recovery projections under per-session keys, migrate legacy v1
data once, isolate quota and storage failures, and preserve the newest
recoverable tail without allowing oversized writes to replace valid state.
Add a real Electron/CDP macOS-arm64 A/B harness with native visibility control,
renderer heartbeat and Settings/composer/transcript checks, plus focused
regressions. Keep bulk tool payloads, diagnostics, and the existing recovery
merge behavior out of the hot path.
Fixes#63047
- Shared per-job trigger controller (apps/shared) coalesces duplicate
clicks for the same profile+job inside a mounted client while letting
unrelated jobs run independently; the backend durable claim remains
authoritative across windows/processes.
- Two-phase feedback everywhere: the action stays disabled/spinning
while the request is in flight and the terminal success/error is
reported once, after the HTTP response — no premature success toast
(Web), matching the Desktop info notification.
- Desktop keeps the 24h trigger timeout for the synchronous long
operation and fences stale profile/list responses and unmounted
surfaces; the sidebar trigger button shows a spinner while busy.
Fixes the #70449 symptom that remained after the two salvaged commits:
opening or viewing a chat whose turn is still running cleared its working
indicator. `session.activate` / `session.resume` report `running` as a
snapshot taken when the RPC was issued; a turn that started or kept
streaming while the RPC was in flight has already marked the runtime busy
in the live cache, and both resume paths overwrote that newer truth with
the stale `running: false`, dropping the session out of the working set
and painting it done mid-turn.
Add `resolveResumedBusy`: a snapshot saying running always wins (adopting
a live turn is never stale), but a snapshot saying idle can no longer
rewind a live busy — the turn's own terminal signal (running:false via
session.info / the settle path) stays the only authority that ends it,
and the background-sync reaper still clears truly lost turns. Wired into
both the warm `session.activate` path and the cold `session.resume` path,
reading the freshest cache entry rather than the pre-await state.
Includes an eslint --fix formatting pass over the touched files.
Salvaged from #51358 (razultull), rebuilt against the rewritten status
architecture on main. The original PR patched setSessionWorking /
noteSessionActivity / the statusbar counter, all of which have since been
replaced (busy now lives in session-states.ts, the watchdog only marks
stalled, and the statusbar Agents item already shows a pure subagent count
— the conflated counter the PR split no longer exists).
What still applied is the core bug: a parent that delegates via
delegate_task(background=true) ends its own turn the moment the handle
returns, so the sidebar row dropped to a plain idle dot while the spawned
subagents kept working for minutes — the session read as "done" mid-task.
Add $delegatingSessionIds — sessions whose subagents are still queued or
running — as an input to the session dot projection, claiming the same
'background' treatment as running background processes (and yielding to
'working' while the parent turn itself is live). Uses the same
runtime→stored bridge, lineage aliasing, and fresh-chat runtime-id
fallback as $backgroundRunningSessionIds, and clears by construction the
moment the last subagent reaches a terminal status.
Three safeguards keep finished chats from looking busy: tool rows seal on turn settle, vanished runtimes clear awaiting state and open tool parts, and late stream events no longer land in a freshly opened session.
The pane-resize sash's 9px grab band was centered on the split boundary,
so ~4.5px reached into the leading pane and sat exactly on top of the
session list's 4px scrollbar — the pointer always hit the sash (cursor
flipped to col-resize) and the scrollbar thumb was unclickable/undraggable.
Make the grab band asymmetric: 1px into the leading pane, 7px into the
trailing one. The scrollbar regains its full hit area while the sash stays
an easy 8px target; the hairline and hover strip are repositioned onto the
actual boundary.
Fixes#79157
Fixes#73495. Two cold-start defects made the configured Hermes Cloud
agent vanish after a Desktop restart even though the persisted Portal
session was still renewable:
1. hasLivePortalSession() trusted the FIRST cookies.get() on the lazy
`persist:` partition. It now reuses the warmOauthCookieStore()
warm-up + bounded reread that hasLiveOauthSession() gained in
PR #67769, so a single hydration false-negative no longer clears the
agent list and flips the panel to signed-out.
2. Discovery required the short-lived `privy-token` access cookie but
treated its absence as a full interactive re-login, even when the
30-day `privy-session` / `privy-refresh-token` renewal cookies
survived the process exit. New cookiesHavePrivyAccessToken() splits
"signed in (renewable)" from "discovery can succeed right now";
discoverCloudAgents() and cloudAgentSilentSignIn() now mint a fresh
access token via one bounded, hidden, deadline-capped portal load
(renewPortalAccessSilently) before or after a 401, and only surface
needsCloudLogin when renewal genuinely cannot complete.
PRIVY_SESSION_COOKIE_VARIANTS also learns `privy-refresh-token` so a
renewal-only jar still counts as signed in rather than demanding an
interactive login while usable refresh material sits in the partition.
Tests: connection-config.test.ts covers the access/session split,
including the exact renewal-only cold-start jar from the issue repro.