Compare commits

...
Author SHA1 Message Date
netcatty-bot 675d7d7489 fix: address Codex review on PR #3696 2026-10-16 07:17:42 +00:00
netcatty-bot c72fde439e fix: address Codex review on PR #3696 2026-10-16 06:54:38 +00:00
netcatty-bot 6c40232556 fix: address Codex review on PR #3696 2026-10-16 06:03:55 +00:00
netcatty-bot c98bd62dae fix: address Codex review on PR #3696 2026-10-16 05:42:54 +00:00
netcatty-bot 269a79683a fix: address Codex review on PR #3696 2026-10-16 05:13:28 +00:00
netcatty-bot cda594263b fix: address Codex review on PR #3696 2026-10-16 04:52:22 +00:00
Codex 5b56871450 Keep SFTP transfers on the pane connection endpoint 2026-10-16 04:10:57 +00:00
Codex 2df507fda1 fix(sftp): refresh transfer acquisition after host edits 2026-10-16 03:27:58 +00:00
Netcatty-Bot 2cfe2a05ea fix(sync-ui): clarify disconnect and preserve failure retry
Make provider disconnect visible and explain the local credentials removed and the vault, backup history, shared sync password and cloud copies retained. Report failed disconnects and preserve a retry path instead of implying success.

Validation: focused sync and dialog checks, isolated Electron cancel/failure/retry/reload against WebDAV, two independent final full-diff reviews, exact-head Codex review and current GitHub checks; the main integration tree also passed focused checks.

Related to #3688. Device handover cleanup remains outside this patch and the issue remains open.
2026-10-16 10:45:01 +08:00
Netcatty-Bot 7ed7250f34 fix(vault): clean deleted jump host references across deletion flows
Deleting saved hosts now removes their jump references from surviving hosts and group defaults in all ordinary deletion flows. Host-only bulk deletion uses the existing vault mutation boundary, and explicit empty chains retain their original group-override meaning.

Validation: 282 focused checks, lint/build, two independent full-diff reviews, latest-head Codex review, current GitHub checks, and actual isolated Electron deletion/restart/native SSH reconnection.

Fixes #3691
2026-10-16 10:38:15 +08:00
陈大猫 2ce0164fa9 Merge pull request #3642 from GOUKI9999/feat/unified-model-catalog-ux
feat(ai): unify SDK model catalog loading and sources
2026-10-15 22:48:27 +08:00
bincxz 2e55b9348f test(ai): keep Copilot fixtures in the managed temp directory 2026-10-15 22:27:11 +08:00
bincxz 1e79dadbe5 fix(ai): let the Copilot SDK own its native runtime directly 2026-10-15 22:13:44 +08:00
bincxz 52e15f8df7 fix(ai): match the official Copilot npm loader package 2026-10-15 21:51:06 +08:00
bincxz 7497e64ab4 fix(ai): preserve custom Copilot JavaScript launchers 2026-10-15 21:48:23 +08:00
bincxz 9888f07cb2 fix(ai): keep Copilot wrapper independent of parent module scope 2026-10-15 21:33:40 +08:00
bincxz 293ca1098e fix(ai): preserve authoritative Grok reasoning capabilities 2026-10-15 21:17:37 +08:00
陈大猫 35a47fe95a Merge pull request #3633 from yuyang2230/feat/cloud-sync-union-merge-and-ai-sessions
feat(sync): non-destructive union merge + AI sessions in cloud sync
2026-10-15 21:09:49 +08:00
bincxz 82add1bda6 test(ai): align Windows Copilot checks with native passthrough 2026-10-15 21:03:04 +08:00
bincxz 239804bf62 fix(ai): retain native Copilot lifecycle and clean undated catalog labels 2026-10-15 21:01:19 +08:00
bincxz 460d9bb7c6 fix(ai): allocate Copilot wrapper through managed temp files 2026-10-15 20:08:44 +08:00
bincxz b7996c58e3 fix(sync): preserve durable chats and stop turns before replacement 2026-10-15 20:05:43 +08:00
bincxz ab50ad93b7 fix(ai): hide irrelevant catalog warnings for configured Codex models 2026-10-15 19:53:28 +08:00
bincxz c86116ac08 fix(ai): keep configured Codex models clear of catalog loading 2026-10-15 19:49:07 +08:00
bincxz 5ac8c8bf1f fix(sync): honor chat deletions and exact backup restoration 2026-10-15 19:33:12 +08:00
bincxz b99de934a3 fix(ai): align catalog source with retained and configured models 2026-10-15 19:30:54 +08:00
bincxz 8e88b99131 fix(sync): preserve chat turn order and recovery boundaries 2026-10-15 19:09:02 +08:00
bincxz d7122683d2 fix(ai): show loading only for pending model catalogs 2026-10-15 19:02:26 +08:00
陈大猫 2c67e3c3ce Merge pull request #2987 from Zeral-Zhang/feature/toggle-window-opacity
feat: toggle window transparency
2026-10-15 19:01:18 +08:00
bincxz 7e9e9b53eb Merge remote-tracking branch 'origin/main' into codex/takeover-pr-3642 2026-10-15 18:58:37 +08:00
陈大猫 e8bbc9a5c9 Merge pull request #3624 from GOUKI9999/fix/model-catalog-blockers
fix(ai): unblock model catalogs for copilot and mimo agents
2026-10-15 18:56:20 +08:00
bincxz 3de34cae5d fix: leave IME processing keys out of transparency toggles 2026-10-15 18:43:56 +08:00
bincxz 7851ddb01e fix(sync): complete chat entity labels and key typing 2026-10-15 18:42:53 +08:00
bincxz de517a6237 fix(sync): honor chat consent during download and initial enable 2026-10-15 18:41:17 +08:00
bincxz aca81953fe fix: keep detached opacity shortcut configuration current 2026-10-15 18:31:34 +08:00
bincxz 9a484ba3cb fix(ai): forward Copilot profile environment to model discovery 2026-10-15 18:30:27 +08:00
bincxz dfc7961b68 fix(sync): complete portable chat and preservation flows 2026-10-15 18:26:17 +08:00
陈大猫 41d2a2d4af Merge pull request #3646 from GOUKI9999/fix/job-startup-diagnostics
feat(ai): instrument job startup pipeline for #3443 diagnostics
2026-10-15 18:23:11 +08:00
bincxz af9ee5d697 fix(ai): preserve completed Grok catalog probes on deadline 2026-10-15 18:17:49 +08:00
bincxz 2aae278d53 fix: isolate settings delivery failures per window 2026-10-15 18:16:28 +08:00
bincxz d9632ab985 Merge latest model catalog fixes from PR 3624 2026-10-15 18:14:00 +08:00
bincxz 82ee429eab fix(ai): distinguish raw probe arrivals from outbound writes 2026-10-15 18:10:01 +08:00
bincxz b70abfd70d test: allow shared opacity in detached settings guard 2026-10-15 18:05:45 +08:00
bincxz 1fe313b229 Merge remote-tracking branch 'origin/main' into codex/pr-2987-takeover 2026-10-15 18:04:15 +08:00
bincxz fd559eb095 fix: keep transparency toggles synchronized and remember opacity 2026-10-15 18:03:47 +08:00
bincxz 3f2b877ba6 Merge commit 'refs/pr-takeover/3633/main' into codex/takeover-pr-3633 2026-10-15 18:02:57 +08:00
bincxz 707a8749c9 fix(ai): keep Copilot selection consistent across discovery states 2026-10-15 18:02:42 +08:00
bincxz 2d5f90ac2e Merge remote-tracking branch 'origin/main' into codex/pr-3624-fix 2026-10-15 18:01:46 +08:00
Fyhax1kiandfyhax1ki d0e024625c feat: add clear-all command history action (#3632)
Add the Global history clear-all action with confirmation and five-language copy. Clear local shell history, autocomplete and compose recall while preserving drafts, keeping peer windows consistent on clears, and allowing new commands afterwards.

Verified with 116 focused checks, two clean independent final reviews, real Electron multi-window SSH-fixture interaction and restart, current-head Codex review, and both required CI jobs.

Co-authored-by: fyhax1ki <fyhax1ki@gmail.com>
2026-10-15 18:00:43 +08:00
bincxz 9a8f9bb0c6 Merge current main into transparency toggle branch 2026-10-15 17:53:14 +08:00
bincxz b512133209 fix(ai): collect worker startup diagnostics in the main log 2026-10-15 17:52:56 +08:00
bincxz 83ee53ba96 Merge remote-tracking branch 'origin/main' into codex/pr-3646-startup-diagnostics
# Conflicts:
#	electron/bridges/ai/ptyExec.cjs
2026-10-15 17:49:45 +08:00
bincxz fa07263628 fix(ai): persist first current-only model catalog 2026-10-15 17:46:59 +08:00
bincxz 08c0ac4f59 fix(ai): preserve model selection on degraded Copilot catalogs 2026-10-15 17:44:32 +08:00
bincxz d298275378 Merge remote-tracking branch 'origin/main' into codex/pr-3624-fix 2026-10-15 17:38:02 +08:00
陈大猫 cdf9b334d5 fix(ai): keep undo branches in the viewing scope (#3686) 2026-10-15 17:22:56 +08:00
陈大猫 158f204baa fix(terminal): withdraw unreleased archive command shortcut
Withdraw the unreleased archive-command shortcut from #3682. Real first-party SFTP locate,auto-run multiline snippets and paste controls showed old filenames preparing commands in a changed directory. The attempted fragment-based tracking required growing state guards,so defer this implementation rather than expand the feature.

The final diff only removes the original seven files/248 added lines.141 related checks,lint,build and real full-app normal-input,SFTP,snippet,existing-link and draft controls passed. Current-head remote review and both required CI jobs passed. Existing AI repairs are untouched.

Related to #3269 and #3682. The feature request stays open. No release.
2026-10-15 16:58:18 +08:00
陈大猫 1c34384651 fix(ai): preserve shell hooks when stopping agent commands
Stopping paced AI input could leave user shell hooks disabled. Remove the unproven PS0 mutation while preserving the existing combined-output fix.

Independent validation:310 checks pass,7 skipped;24 real Bash/SSH normal,cancellation and combined-output controls. Current-head remote review,automated tests and all platform packages passed.

Related to #3683 and #3485. Original reporter acceptance remains pending. No release.
2026-10-15 16:25:25 +08:00
GOUKI9999 e8ad5b3d29 Merge remote-tracking branch 'fork/fix/model-catalog-blockers' into pr3642-fix 2026-10-15 10:54:31 +08:00
GOUKI9999andClaude Sonnet 5.5 b7953a6b26 fix(ai): require advertised thinking levels for suffixed current ids; fingerprint ANTHROPIC_BASE_URL
- Last-known-good serving keeps a decorated current id only when the base row
  advertises that level (same rule as the renderer's modelPresetsContainId).
- ANTHROPIC_BASE_URL joins the main-process and renderer catalog cache keys so
  a different Claude endpoint never serves another endpoint's persisted list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-15 10:54:16 +08:00
GOUKI9999andClaude Sonnet 5.5 0a01af5144 fix(ai): use raw probe byte counter in events; log worker-side job misses
- probe-output-arrived and probe-reply-parsed report the cumulative raw
  byte count, consistent with the startup timeout record.
- A poll/stop that the worker answers with 'Background job not found' while
  the route still exists now emits job-stop-miss (worker-job-missing). The
  route itself is left alone; this PR stays diagnostics-only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-15 10:53:22 +08:00
GOUKI9999 848f201cf9 Merge remote-tracking branch 'fork/fix/model-catalog-blockers' into pr3642-fix 2026-10-14 15:58:44 +08:00
GOUKI9999andClaude Sonnet 5.5 5c901c6b04 fix(ai): keep effort-decorated current id from last-known-good when its base row exists
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:58:31 +08:00
GOUKI9999andClaude Sonnet 5.5 de3410f6e1 fix(ai): name the first probe output checkpoint honestly
The first decoded chunk while probing may be only the echoed probe command,
so probe-reply-arrived overstated what was seen. Rename it (and the timeout
stat) to probe-output-arrived; probe-reply-parsed remains the proof of reply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:57:44 +08:00
GOUKI9999andClaude Sonnet 5.5 a76971e2c5 fix(ai): keep Grok effort-decorated current id, ASCII-only sources; merge #3624 fixes
- last-known-good serving keeps grok-4.6/high when the grok-4.6 row exists.
- Replace non-ASCII dashes/middle dot in files that were ASCII-only.
- Merge #3624 (suffixed Copilot row, retry warning on retained catalog).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:13:39 +08:00
GOUKI9999andClaude Sonnet 5.5 84bf1cbfff fix(ai): keep suffixed Copilot selection selectable and keep retry warning on retained catalog
- appendRetainedStoredModelPreset appends the exact suffixed id when a
  base-only row exists that does not cover the suffix.
- createSdkRuntimeModelCache carries a degraded refresh's warning onto the
  retained cached catalog so the banner and Retry stay visible.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:10:39 +08:00
GOUKI9999andClaude Sonnet 5.5 d4d07d48af fix(ai): report route-removed once and count cmd-write bytes as UTF-8
- deleteWorkerRoute only emits when it actually removes the matching route,
  so a concurrent cancel plus terminal cleanup yields one record.
- cmd-write.bytes uses Buffer.byteLength instead of UTF-16 length.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 15:05:50 +08:00
GOUKI9999andClaude Sonnet 5.5 1ff40e859c fix(ai): return no runtime presets for an empty catalog and sync GROK_CONFIG_DIR
- an empty catalog now yields no runtime presets even when the user has custom
  composer models, so the built-in fallback list stays visible
- add GROK_CONFIG_DIR to the renderer cache env hints (the sync test caught
  the drift after the main-process key was added)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-14 00:01:42 +08:00
GOUKI9999 edf07fc74e Merge branch 'pr3624-fix' into pr3642-fix 2026-10-13 23:59:44 +08:00
GOUKI9999andClaude Sonnet 5.5 6a1bea2bce fix(ai): count raw probe bytes, log lookup misses in the main handlers, keep diagnostics ASCII
- count probe bytes from the raw chunk before decoding, so an incomplete UTF-8
  prefix and multi-byte text are reported in bytes
- log job-stop-miss from the main-process poll/stop handlers too: once the
  worker route is gone the dispatcher falls through to them
- replace the circled numeral in the new diagnostics module

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:59:32 +08:00
GOUKI9999andClaude Sonnet 5.5 7a1f415a91 fix(ai): fingerprint the Grok login directory and keep the persistence module ASCII-only
- add GROK_CONFIG_DIR to the model catalog cache env keys so two grok login
  profiles sharing HOME and the binary do not share a catalog
- replace the em dash added in the current-only refresh comment

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:57:01 +08:00
GOUKI9999andClaude Sonnet 5.5 745f6a3120 test(ai): expect the Copilot headless wrapper on Windows only
The wrapper is no longer used on POSIX, so the two driver tests that asserted
it on every platform now assert the direct CLI path there.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:49:21 +08:00
GOUKI9999andClaude Sonnet 5.5 19faa554a4 fix(ai): address review feedback on the unified model catalog UX
- generate the Copilot headless wrapper in the managed temp directory, create it
  exclusively and only reuse an existing file whose content matches; a tampered
  file is not handed to the SDK
- use the headless wrapper on Windows only; POSIX passes the resolved CLI straight
  to the SDK so forceStop() reaches the real process
- keep the renderer model-cache env hints in sync with the main-process keys
  (CODEBUDDY_* and XAI_API_KEY) and guard it with a test
- forward source/fetchedAt from list-models so a last-known-good catalog is not
  labelled live
- do not retain a saved Claude alias on top of an empty catalog, which would turn
  a one-row list into the authoritative one and hide the built-in presets
- ASCII-only comments in the two new modules

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:40:33 +08:00
GOUKI9999 c0a66f1e88 Merge branch 'pr3624-fix' into pr3642-fix
# Conflicts:
#	components/AIChatSidePanelHelpers.test.tsx
#	electron/bridges/aiBridge/sdk/copilotDriver.test.cjs
2026-10-13 23:32:10 +08:00
GOUKI9999andClaude Sonnet 5.5 6a79400112 fix(ai): address review feedback on the model catalog PR
- keep an already-qualified {provider, model} default in getOpenCodeDefaultModelIds
  instead of prefixing the provider twice
- resolve npm's copilot.ps1 wrapper to the JS entry on Windows like .cmd/.bat
- when serving a last-known-good catalog, remap a persisted current id that has
  no row to the first visible row for every backend (null still means default),
  so the picker and the send preflight agree
- reconcile the catalog warning banner from the result of every refresh, so a
  panel that joined another panel's in-flight load also shows it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 23:31:08 +08:00
GOUKI9999andClaude Sonnet 5.5 13bcf47282 fix(ai): address review feedback on the job startup diagnostics
- keep jobDiag.stats.probeBytes in sync so the startup-timeout event reports
  the bytes of a partial probe reply
- route every worker route-map deletion through one helper that emits
  route-removed (poll/stop completion, stale check, chat cancel, terminal close)
- label a poll/stop miss as route-missing, scope-mismatch or
  worker-unavailable instead of always route-missing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SX3d2XMHxBEpU7VqpvXbdm
2026-10-13 21:32:28 +08:00
GOUKI9999 e5abae559c feat(ai): instrument job startup pipeline for #3443 diagnostics
Add read-only [Netcatty JobDiag] timeline emission covering the four
checkpoints requested in #3443: probe write/reply/parse (2), command
write + start marker (3), startup timeout with byte stats, interrupt
delivery with flow-control snapshots, and the MCP background-job route
lifecycle (4). Every payload is keyed by sessionId + jobId (jobId was
previously absent from all logs) and carries monotonic at/sinceStartMs
timestamps.

interrupt-callback-invoked reports finishedAtCallback so the recovery
ordering finding from the #3443 investigation is directly observable;
interrupt-callback-skipped fires when recovery is skipped (no callback
wired or callback threw). Checkpoint 1 (flow-control samples) remains
covered by the existing [Netcatty Ctrl+C] / [Netcatty Terminal Perf]
emitters; session snapshots here reuse the same helpers.

Enabled via NETCATTY_JOB_DIAG=1 or the existing NETCATTY_TERMINAL_DEBUG=1.
Zero behavior change: instrumentation calls only, jobId/sessionId/
diagnosticsSession are optional startPtyJob options defaulting to null.
2026-10-13 11:44:59 +08:00
GOUKI9999 d653180c3c fix(ai): union the grok ACP and CLI catalogs so a session subset cannot shrink the picker
- ACP initialize _meta.modelState reflects the negotiated agent session and
  has been observed to advertise only a subset of the account's models
  (e.g. grok-4.6/4.5 without the 4.7 family), while `grok models` lists the
  full account set; the old short-circuit (non-empty ACP catalog wins) made
  the live picker silently drop models one source knows about
- run both probes together and merge by id: ACP entries win for shared ids
  because they carry live vendor _meta (reasoning efforts, description);
  fields an ACP entry lacks are filled from the CLI row
- tests: mergeGrokCatalogModels unit cases (subset union, dedupe, field
  fill, malformed sides) and registry-level union/no-dupe coverage
2026-10-13 10:43:08 +08:00
GOUKI9999 0006634f6b fix(ai): show build-time presets while the opencode catalog loads
opencode was the only SDK agent whose picker rendered nothing until its
live catalog pull resolved: the #3584 suppression replaced presets with
just the user's custom ids while runtimeModelLoading was set. Align it
with every other backend — presets stay selectable during loading, the
live catalog replaces them on arrival, and #3585's retry/warmer
mechanics are untouched. Adds a regression guard against reintroducing
the opencode gating.
2026-10-12 18:12:34 +08:00
GOUKI9999 729ffcf162 fix(ai): capture dash-bullet grok model entries in the CLI catalog parse
Ground truth from grok 1.0.50 (`grok --no-auto-update models`): the default
model is listed with a "*" bullet but every other entry uses "-", so the
previous ^\*+-only regex silently dropped 3 of 4 live models and the
"live" picker shrank below the build-time presets. Accept any bullet
marker (*/-/*) so separator dashes still cannot match. Adds the captured
raw-output fixture plus an ACP initialize fixture (grok-4.7 / 4.7-build-fast /
4.6 / 4.5) asserting the registry ACP path keeps every availableModels entry.
2026-10-12 18:12:34 +08:00
GOUKI9999 e642c4953f feat(ai): unify model catalog UX across SDK agents
Implements the unified model catalog UX on top of the last-known-good
persistence layer (#3624):

- R1 source provenance end-to-end: list-models now returns
  source: 'live' | 'last-known-good' plus fetchedAt (ISO) on live
  successes and last-known-good serve-outs; the pre-flight cache replays
  the original provenance on hits. The renderer cache persists
  source/fetchedAt alongside the catalog, and the build-time preset
  fallback derives 'presets'.
- R2 loading state for all backends: drop the opencode-only gate on
  modelCatalogLoading so every SDK backend shows the composer loading
  state while its live fetch is in flight (opencode behavior unchanged).
- R3 unified status chip: ComposerModelPicker renders a muted footer chip
  for every backend - live / cached with relative last-success time /
  built-in default list - replaced by the spinner row while loading.
  Keys added to en/es/zh-CN/zh-TW (ru keeps its pre-existing ai.opencode
  gap).
- R4 copilot headless wrapper: generated per-resolve wrapper (baked with
  the platform exe the npm loader would spawn) is handed to
  RuntimeConnection.forStdio, spawning with windowsHide:true so no
  console window flashes in Electron. Falls back to the loader when the
  exe cannot be resolved (window may flash - documented degradation).
2026-10-12 16:53:00 +08:00
GOUKI9999 cb737ba0c9 fix(ai): keep refreshed current rows readable, share the mimo catalog deadline, and isolate grok catalog env
- modelCatalogLastKnown: a current-only refresh to an id absent from the
  persisted list now synthesizes a minimal row (label = the id) instead of
  leaving the persisted current id dangling, so a later live failure serves
  a list whose current row the picker and the send preflight agree on
- mimoDriver: sdkStreamHandlers hands an absolute catalog deadline down
  (outer budget minus 1s headroom); mimo serve readiness and the providers()
  query draw from that one pool instead of re-arming independent 25s caps,
  so the pair can no longer outrun the 30s outer model-list budget
- sdkStreamHandlers: add XAI_API_KEY to SDK_MODEL_CACHE_ENV_KEYS so two
  grok profiles sharing one binary but not credentials never share a
  catalog (MODEL_CACHE_ENV_HINTS does not exist in this tree)
2026-10-12 15:01:19 +08:00
GOUKI9999 c6a1f11fc9 fix(ai): preserve current-only selections, isolate codebuddy catalog env, and robusten copilot auto fallback
- last-known-good store: a current-only catalog (zero visible models,
  valid currentModelId) now refreshes the persisted current id via
  refreshLastKnownCatalogCurrentId instead of being skipped, so the
  user's selection survives a later live failure without clobbering
  the persisted models list
- SDK_MODEL_CACHE_ENV_KEYS: add CODEBUDDY_API_KEY, CODEBUDDY_AUTH_TOKEN
  and CODEBUDDY_INTERNET_ENVIRONMENT so two codebuddy profiles sharing a
  binary but not credentials no longer share one catalog cache key
- isCopilotAutoOnlyFallback: detect the copilot auto row among the
  renderer-appended rows (composer custom ids, retained selections)
  instead of requiring exact single-entry shape, keeping saved explicit
  selections valid when the picker list mixes extra rows
2026-10-12 12:29:36 +08:00
陈奇 5ed58345aa fix(ai): preserve unspecified effort on explicit Claude models 2026-10-12 04:06:53 +00:00
GOUKI9999 5f29d91499 fix(ai): keep effort suffixes on retained copilot preset rows
appendRetainedStoredModelPreset reduced a saved selection to its base id,
so a suffixed saved copilot model (e.g. claude-sonnet-4.5/high) produced
a row that no longer matched the stored id: the picker fell back to auto
while the send preflight passed the saved id. Append the exact stored
spelling when no build-time preset defines the base id.
2026-10-12 11:53:15 +08:00
GOUKI9999 5d9c8f29de fix(ai): qualify provider/model defaults before the model-only branch
getOpenCodeDefaultModelIds checked the bare value.model string before the
provider-aware shapes, so {provider: 'xiaomi', model: 'chosen'} returned
the unqualified 'chosen' and mimo's visibility check could never match
its fully qualified model ids. Mirror the singular resolver's branch
order.
2026-10-12 11:53:15 +08:00
陈奇 355d53abe3 fix(ai): retain degraded catalog warnings through cache copies 2026-10-12 03:40:23 +00:00
GOUKI9999 1ee0f8441c fix(ai): keep preserved saved models visible in the picker and cache fallback warnings
A stored selection preserved through the claude-alias or copilot
auto-only degraded fallback had no preset row, so the picker could show
no selected row while the send preflight passed the hidden value; append
a preset row (reusing the build-time preset when it defines the id).

Last-known-good responses were cached without their warning, so another
panel or a remount within the renderer TTL presented degraded models as
live and hid the Retry action. The warning now travels with the cached
catalog and re-syncs the banner on cached hits.
2026-10-12 11:36:19 +08:00
GOUKI9999 c396fc78ef fix(ai): resolve the mimo default across a multi-key default map
getOpenCodeDefaultModelId returns only the first default entry, so a
catalog declaring the hidden auto channel before the xiaomi provider made
the filtered catalog fall back to the first model instead of the CLI's
configured Xiaomi default. Enumerate every advertised default and select
the first one that survives filtering.
2026-10-12 11:36:19 +08:00
GOUKI9999 c48514381e fix(ai): preserve a saved Copilot model during degraded discovery
The auto-only COPILOT_MODEL_PRESETS fallback is nonempty, so a live
listModels failure invalidated a saved explicit Copilot model selection:
shouldUseStoredAgentModel only preserved unknown stored ids when the
preset list was empty, and picker plus send path silently switched to
auto. A saved id keeps resolving while the presets are exactly the
auto-only fallback.
2026-10-12 11:15:53 +08:00
GOUKI9999 5ae5965556 fix(ai): keep the current model inside the persisted catalog cap
writeLastKnownCatalogEntry sliced models to the 200-entry cap without
checking whether the current model survived the slice, so a huge live
catalog could persist a currentModelId with no picker row and the
last-known-good serve would let the picker and the send preflight
disagree again. Retain the current model in the capped set.
2026-10-12 11:15:52 +08:00
GOUKI9999 0050e895df Merge fork/fix/model-catalog-blockers: adopt upstream fixes and keep complementary catalog hardening
Adopts the PR author's commits for the copilot shutdown race, the mimo
filtered-channel default, and the claude alias fallback presets. Keeps the
complementary pieces: last-known-good mimo read-back normalization and
renderer-side preservation of stored claude aliases against live full-id
catalogs.
2026-10-12 11:07:15 +08:00
GOUKI9999 060fbbb2d1 fix(ai): keep stored claude alias selections when the catalog lists full ids
Preset lists before the 2026-10 claude catalog refresh persisted
sonnet/haiku alias ids. A live catalog of full ids made
shouldAdoptSdkCurrentModel treat such a stored selection as stale and
silently overwrite it, even though the claude CLI still resolves the
alias. Stored aliases now keep resolving (picker and send path) and are
never replaced by the catalog's current model; other backends and
non-alias ids are unchanged.
2026-10-12 11:00:49 +08:00
GOUKI9999 8c64280d87 fix(ai): cancel the losing copilot shutdown timer and force-stop on rejection
stopCopilotClient's 3s grace timer stayed alive whenever stop() won the
race, leaking one handle per turn/catalog load. Clear it in a finally. A
stop() rejection also no longer counts as settled, so the forceStop()
fallback still runs when the graceful handshake fails.
2026-10-12 11:00:49 +08:00
GOUKI9999 422bf120a9 fix(ai): keep the mimo current id off the filtered auto channel
A live default on the filtered mimo auto channel surfaced a currentModelId
the picker could not render or check while sends still went through the
channel. Replace it with the vendor's documented default when the remaining
models offer it, else the first remaining model, else null; ids outside the
filtered channel pass through untouched. Apply the same normalization when
serving a persisted last-known-good catalog captured before filtering.
2026-10-12 11:00:35 +08:00
陈奇 555e911887 fix(ai): preserve saved Claude fallback aliases 2026-10-12 02:45:39 +00:00
陈奇 74b7ce6c8c fix(ai): align MiMo catalog default with visible models 2026-10-12 02:45:39 +00:00
陈奇 3b78e097e6 fix(ai): force Copilot shutdown after stop rejection 2026-10-12 02:45:39 +00:00
xu 6052cbbd1f feat(sync): non-destructive union merge + AI sessions in cloud sync
Cloud sync uploaded the whole encrypted vault as a single payload. Two
problems follow from that, both of which we hit in production:

1. A one-sided deletion propagated to every device. Restoring an older
   local backup is indistinguishable from "the user deleted these 25
   hosts", so the next sync happily overwrote the cloud replica and
   every other machine with the older, smaller vault.

2. AI chat sessions were not part of the sync payload at all, so they
   never travelled between machines and never appeared in any backup.

This change adds an opt-in "keep all" merge and makes sessions syncable.

## Non-destructive merge

`mergeSyncPayloads` gains `{ preserveDeletions }`. When set, an entity
missing on one side is carried forward from the side that still has it;
only entities absent from base *and* both sides are removed. It is
exposed as the new `smartMergePreserve` strategy ("Smart merge (never
delete)") and threaded through every merge call site:

- `providerSyncMethods.syncToProviderImpl`
- `syncAllStorageMethods`
- `useAutoSync`

Default behaviour (`smartMerge`) is unchanged, so existing users see no
behaviour change until they opt in.

## AI sessions in the payload

`SyncPayload.aiSessions` is new and opt-in via the
"Sync AI chat history" switch in Settings → Cloud Sync. Sessions are
unioned by id, and messages are merged **append-only**: a message is
identified by id and the merged transcript is the union of all three
sides ordered by timestamp. Two devices that appended different turns to
the same conversation therefore keep both turns, instead of one device
silently dropping the other's work.

Applying a downloaded payload unions with the local transcript rather
than replacing it, so an older cloud snapshot can never delete turns
that only exist locally.

Inline attachment blobs (`base64Data`) are stripped before upload —
image/PDF payloads would multiply the encrypted vault size and may carry
data the user never intended to leave the device. Attachment metadata
(filename, media type, line count) is kept for context.

## Tests

- `domain/syncMerge.preserveDeletions.test.ts` — one-sided deletions
  (local and remote), both-sides deletion, additions, notes/snippets/
  port-forwarding/string entities, idempotence, summary reporting.
- `domain/syncMerge.aiSessions.test.ts` — session union, append-only
  message merge, timestamp ordering, metadata LWW, strategy-aware
  deletion, attachment stripping, idempotence.

Verified locally: 1939/1939 tests pass across the domain, convergentSync,
i18n, cloudSync and application sync suites; eslint clean on all touched
files.
2026-10-11 15:48:54 +08:00
GOUKI9999 ecd988ea4c fix(ai): resolve last-known catalog path via app.getPath and bound persisted entries
Review feedback on PR #3624:
- resolveLastKnownCatalogPath now reads userData through
  electronModule.app.getPath, matching the real bootstrap where
  electronModule is the require('electron') namespace (previously the
  persistence layer was a silent no-op in production)
- the last-known store now caps entries at LAST_KNOWN_MAX_ENTRIES (32,
  mirroring the in-memory TTL cache) and evicts the least recently
  updated entries, so churned fingerprinted cache keys cannot grow the
  store file without bound
- replace em dashes with ASCII punctuation per repo convention
- test stub now mirrors the real electronModule shape
2026-10-10 19:15:53 +08:00
GOUKI9999 258d72bdca fix(ai): restore copilot loader-first resolution and add copilot/mimo preset fallbacks
Revert d2b1d5fa2's default preference of the native copilot.exe: the
1.0.59-era native CLI bootstraps %LOCALAPPDATA%\copilot\pkg\ on first
start, starts slowly, and leaves a conhost child alive after app-side
timeout/abort (console window stays open; CLI server exited unexpectedly
with code 0). Loader-first (npm-loader.js) is the default again; the
native binary is opt-in via NETCATTY_COPILOT_PREFER_NATIVE=1.

Also:
- Add COPILOT_MODEL_PRESETS (auto) so a live listModels failure no
  longer leaves the Copilot picker empty.
- Sync MIMO_MODEL_PRESETS to the 6 verified live-catalog models
  (v2.6/v2.5 pro-ultraspeed/pro/flash); auto channel stays filtered
  driver-side.
- Reap the Copilot CLI child on abort/stop: stopCopilotClient() races
  graceful client.stop() with a 3s grace and falls back to the SDK's
  forceStop() (SIGKILL) so the spawned process does not linger.
2026-10-10 19:07:11 +08:00
GOUKI9999 157e5f03f0 Merge branch 'fix/model-catalog-blockers' into test/model-catalog-plus-presets 2026-10-10 18:37:56 +08:00
GOUKI9999 d2b1d5fa20 fix(ai): prefer the native copilot.exe over npm-loader.js in SDK resolution
npm-loader.js re-spawns the native binary via spawnSync WITHOUT
windowsHide, flashing a console window when the Copilot SDK runs inside
Electron's GUI main process on Windows. When the platform-native binary
(@github/copilot-<platform>-<arch>/copilot.exe) exists next to the
resolved package, hand it to RuntimeConnection.forStdio directly — the
SDK spawns it itself with windowsHide:true. Fall back to npm-loader.js
when the native package is absent (e.g. optional deps omitted).
2026-10-10 18:33:47 +08:00
GOUKI9999 cc6ba30891 Merge remote-tracking branch 'fork/fix/claude-preset-refresh' into test/model-catalog-plus-presets 2026-10-10 18:21:10 +08:00
GOUKI9999 2fefdf6f0c Merge branch 'fix/model-catalog-blockers' into test/model-catalog-plus-presets 2026-10-10 18:21:09 +08:00
GOUKI9999 01d6c4ceaf fix(ai): filter the mimo auto channel out of the live catalog 2026-10-10 18:20:04 +08:00
GOUKI9999 9b7d2a6a34 fix(ai): expand Claude fallback presets to the full live-generation list 2026-10-10 18:10:40 +08:00
GOUKI9999 d79a4de401 Merge fork/fix/claude-preset-refresh into test/model-catalog-plus-presets 2026-10-10 17:49:19 +08:00
GOUKI9999 9a17691c32 fix(ai): widen the mimo inner catalog setup budget to 25s
The catalog path boots a dedicated `mimo serve` and then queries providers();
the inner awaitMimoSetup race (and the serve readiness timeout) was 10s and
lost on the coldest boots even though the outer mimo budget is now 30s. Give
the catalog path a 25s inner budget (MIMO_CATALOG_SETUP_TIMEOUT_MS, passed as
both the spawn readiness timeout and the providers race). runMimoTurn keeps
the 10s default.
2026-10-10 17:11:21 +08:00
GOUKI9999 c22bf0ab9b feat(ai): fall back to last-known-good model catalogs on live fetch failure
Persist each successful live catalog ({userData}/model-catalog-last-known.json,
keyed by the same backend+binPath+runtime+envHash string as the in-memory TTL
cache) and serve it when a later live fetch fails, before degrading to curated
presets. Copilot has no presets at all, so this is its first real fallback.
Empty catalogs are never persisted; corrupt files and IO errors degrade
gracefully; per-entry models are capped at 200. The response keeps the
warning contract and adds source/fetchedAt markers for the UI.
2026-10-10 17:11:21 +08:00
GOUKI9999 494cd57e25 fix(ai): refresh Claude fallback presets to the 5.x generation
The curated CLAUDE_MODEL_PRESETS fallback still advertised the 4.6
generation while the live CLI catalog serves Opus/Sonnet/Haiku 5.5 and
Fable 5.1. Alias ids (`default`/`sonnet`/`haiku`) are unchanged so the
CLI still resolves them; only display names move: sonnet/haiku now map
to claude-sonnet-5-5 / claude-haiku-5-5 in the Claude CLI alias table
(verified against CLI 2.1.296), and `default` has no fixed alias target,
so it keeps the CLI's own neutral "Default (recommended)" label.
2026-10-10 16:39:45 +08:00
GOUKI9999 f0169ad632 fix(ai): resolve copilot .cmd shim before spawning SDK
RuntimeConnection.forStdio({ path }) makes the Copilot SDK spawn the
CLI through node WITHOUT shell:true. Windows npm installs expose
copilot as a .cmd shim, and Node >= 18.20 throws spawn EINVAL for
.cmd/.bat in milliseconds, so listCopilotModels()/runCopilotTurn()
could never start the runtime via the PATH-resolved shim.

Add resolveCopilotExecutableForSdk() to shellUtils (same per-CLI
resolver pattern as claude/codex/codebuddy): resolve the shim to the
package's real npm-loader.js entry via the npm layout shortcut, then
by parsing the shim, and pass .exe/.js entries through unchanged.
Apply it at both RuntimeConnection.forStdio call sites; unresolvable
shims keep the original path so behavior is unchanged.

Harness: PATH-resolved copilot.cmd -> npm-loader.js, catalog [auto]
returned in 3.0-3.6s (was: EINVAL in 1-2ms, empty catalog).
2026-10-10 15:51:21 +08:00
GOUKI9999 df74164d54 fix(ai): skip skill-path probe in mimo catalog and extend its list timeout
listMimoModels() spawned `mimo debug config` only to seed the server's
skills config, which the provider catalog never reads. That probe alone
costs 6-8s on a cold CLI, pushing the whole chain (probe + serve boot +
config.providers()) past the shared 10s model-list timeout, so the mimo
catalog always degraded to an empty list with a UI warning.

- drop the readMimoSkillPaths() call from listMimoModels(); the turn
  path (runMimoTurn) still reads skill paths unchanged
- add a per-backend catalog timeout override map; mimo gets 30s because
  it cold-boots a dedicated `mimo serve` process per catalog pull
  (unlike OpenCode's pooled server), while every other backend keeps
  the existing 10s (opencode: 10s + server start) budget

Harness: no debug-config spawn, catalog 7 models in 7.7-10.5s (was:
timeout error at 10s, empty catalog every time).
2026-10-10 15:51:07 +08:00
Zeral 565ab504f4 Merge branch 'main' into feature/toggle-window-opacity 2026-08-24 13:13:26 +08:00
Zeral Zhang 9958ee5097 feat: toggle window transparency 2026-08-14 20:11:57 +08:00
166 changed files with 8703 additions and 657 deletions

No files matched your search

-1
View File
@@ -129,7 +129,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Session management** — run multiple connections side-by-side
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
- **Archive shortcuts** — Ctrl+click a printed tar/zip filename at an empty Linux shell prompt to prepare an extraction command; press Enter to run it. Relative filenames are available only in the latest command's output; run `ls` again after submitting another command. Existing input is kept, and shortcuts stay off for unknown systems and broadcast mode. Names with spaces must be printed in quotes; wrapped filenames are excluded.
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
@@ -699,6 +699,89 @@ test('connection log host snapshot includes custom host icon fields', () => {
);
});
test('toggleOpacity hotkey invokes toggleWindowOpacity', () => {
let called = false;
const noop = () => {};
executeHotkeyActionImpl(
() => ({
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen: false,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: [],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: noop,
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen: noop,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
toggleWindowOpacity: () => { called = true; },
toggleWorkspaceViewMode: noop,
workspaces: [],
}),
'toggleOpacity',
{ key: 'u', code: 'KeyU', ctrlKey: true, shiftKey: true } as KeyboardEvent,
);
assert.equal(called, true);
});
for (const scenario of [
{ name: 'IME process key with its original physical code', key: 'Process', keyCode: 229, isComposing: false, expected: false },
{ name: 'key during composition', key: 'U', keyCode: 85, isComposing: true, expected: false },
{ name: 'ordinary shortcut', key: 'U', keyCode: 85, isComposing: false, expected: true },
]) {
test(`transparency shortcut respects ${scenario.name}`, () => {
const target = new FakeHTMLElement();
const actions: string[] = [];
let prevented = false;
const event = {
...scenario,
code: 'KeyU',
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => actions.push(action),
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}), event);
assert.deepEqual(actions, scenario.expected ? ['toggleOpacity'] : []);
assert.equal(prevented, scenario.expected);
});
}
for (const scenario of [
{ name: 'single orphan', active: 'one', sessions: [{ id: 'one' }], available: false, expected: null },
{ name: 'two orphans', active: 'one', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: 'global' },
+9 -1
View File
@@ -306,6 +306,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
// IME-filtered keys retain their physical code (e.g. Linux Unicode entry).
// Let composition handle them instead of treating them as opacity toggles.
if (binding.action === 'toggleOpacity' && (e.isComposing || e.keyCode === 229 || e.key === 'Process')) {
return;
}
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
@@ -781,7 +786,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWindowOpacity, workspaces } = getCtx();
{
const shortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
@@ -971,6 +976,9 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
setNavigateToSection('snippets');
}
break;
case 'toggleOpacity':
toggleWindowOpacity();
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
+13 -4
View File
@@ -42,6 +42,7 @@ import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
import { upsertKnownHost } from '../../domain/knownHosts';
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
import { useVaultHostDeletion } from '../state/useVaultHostDeletion';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
@@ -151,6 +152,7 @@ export function AppSideEffects() {
showSftpTab,
shellOnlyTabNumberShortcuts,
workspaceFocusStyle,
toggleWindowOpacity,
} = settings;
// Always publish terminal keyboard focus to the main process. When font zoom
@@ -1079,6 +1081,7 @@ export function AppSideEffects() {
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
toggleWindowOpacity,
toggleWorkspaceViewMode,
workspaces: workspacesRefForHotkeys.current,
}), action, e);
@@ -1091,6 +1094,7 @@ export function AppSideEffects() {
splitSessionWithCurrentShell,
moveFocusInWorkspace,
toggleBroadcast,
toggleWindowOpacity,
toggleGlobalBroadcast,
toggleWorkspaceViewMode,
confirmIfBusyLocalTerminal,
@@ -1196,11 +1200,16 @@ export function AppSideEffects() {
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
}, [hosts]);
const handleConfirmDeleteHost = useCallback(() => {
const deleteVaultHosts = useVaultHostDeletion(commitVaultGroupMutation);
const handleConfirmDeleteHost = useCallback(async () => {
if (!deleteHostConfirm) return;
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
setDeleteHostConfirm(null);
}, [deleteHostConfirm, hosts, updateHosts]);
try {
await deleteVaultHosts([deleteHostConfirm.hostId]);
setDeleteHostConfirm(null);
} catch {
toast.error(t('common.error'));
}
}, [deleteHostConfirm, deleteVaultHosts, t]);
const handleCancelDeleteHost = useCallback(() => {
setDeleteHostConfirm(null);
+2 -1
View File
@@ -261,7 +261,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -749,6 +749,7 @@ function AppViewInner({ domains }: AppViewProps) {
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleTerminalCommandExecuted}
onDeleteShellHistoryEntry={removeShellHistoryEntry}
onClearCommandHistory={clearAllCommandHistory}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
+1
View File
@@ -36,6 +36,7 @@ export function VaultHost() {
return {
addShellHistoryEntry: actions?.addShellHistoryEntry,
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
clearAllCommandHistory: actions?.clearAllCommandHistory,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
@@ -72,6 +72,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
clearAllCommandHistory,
} = vault;
useLayoutEffect(() => {
@@ -144,12 +145,14 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
updateHostLastConnected,
addShellHistoryEntry,
removeShellHistoryEntry,
clearAllCommandHistory,
});
return () => {
registerVaultSnapshotActions(null);
};
}, [
addShellHistoryEntry,
clearAllCommandHistory,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
+2
View File
@@ -14,6 +14,7 @@ import {
type ConvergentMigrationProviderInput,
} from '../domain/convergentSync';
import { isProviderReadyForSync } from '../domain/sync';
import { isPreserveDeletionsStrategy } from '../domain/syncStrategy';
import { getCloudSyncManager, type CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
import { markConvergentSyncInitialized } from '../infrastructure/services/convergentSyncConfig';
import { applyProtectedSyncPayload } from './localVaultBackups';
@@ -113,6 +114,7 @@ export async function prepareConvergentSyncMigration(
providers: inputs,
deviceId: manager.getState().deviceId,
now,
preserveDeletions: isPreserveDeletionsStrategy(manager.getState().syncStrategy),
});
if (plan.state) {
for (const seed of legacyBaselineSeeds) {
@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import en from '../locales/en.ts';
import ru from '../locales/ru.ts';
import es from '../locales/es.ts';
import zhCN from '../locales/zh-CN.ts';
import zhTW from '../locales/zh-TW.ts';
const keys = [
'cloudSync.provider.disconnect',
'cloudSync.provider.disconnect.confirmTitle',
'cloudSync.provider.disconnect.confirmMessage',
'cloudSync.provider.disconnect.confirmAction',
'cloudSync.provider.disconnect.toastTitle',
'cloudSync.provider.disconnect.cloudRemains',
'cloudSync.provider.disconnect.failed',
] as const;
test('disconnect copy exists in every bundled locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
for (const key of keys) {
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
}
}
});
test('disconnect confirm copy states that saved provider credentials are removed', () => {
// The confirm dialog must explain the data consequence of disconnecting:
// locally saved endpoint + credentials removal, not just "stops syncing".
const expected = [
[en, 'will be removed'],
[es, 'eliminarán'],
[ru, 'будут удалены'],
[zhCN, '删除'],
[zhTW, '刪除'],
] as const;
for (const [messages, fragment] of expected) {
assert.ok(
(messages['cloudSync.provider.disconnect.confirmMessage'] as string).includes(fragment),
`confirmMessage should mention credential removal: ${fragment}`,
);
}
});
test('disconnect toast copy explains the cloud copy is retained', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
assert.ok(
(messages['cloudSync.provider.disconnect.cloudRemains'] as string).includes(
'{name}',
),
`${locale} cloudRemains hint should reference the provider name`,
);
}
});
test('disconnect copy distinguishes login cleanup from retained local data', () => {
assert.match(en['cloudSync.provider.disconnect.confirmMessage'], /backup history and sync encryption password stay here/);
assert.doesNotMatch(en['cloudSync.provider.disconnect.cloudRemains'], /change.*password/);
for (const messages of [en, ru, es, zhCN, zhTW]) {
assert.ok(messages['cloudSync.provider.disconnect.failed'].includes('{name}'));
}
});
@@ -11,10 +11,16 @@ const strategyKeys = [
"cloudSync.strategy.desc",
"cloudSync.strategy.smartMerge",
"cloudSync.strategy.smartMergeDesc",
"cloudSync.strategy.smartMergePreserve",
"cloudSync.strategy.smartMergePreserveDesc",
"cloudSync.strategy.preferCloud",
"cloudSync.strategy.preferCloudDesc",
"cloudSync.strategy.preferLocal",
"cloudSync.strategy.preferLocalDesc",
"cloudSync.aiSessions.title",
"cloudSync.aiSessions.desc",
"cloudSync.aiSessions.compatibility",
"cloudSync.aiSessions.enableFailed",
] as const;
test("cloud sync strategy copy exists in every bundled locale", () => {
+5
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const enAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
@@ -513,6 +514,10 @@ export const enAiMessages: Messages = {
'ai.chat.pinModel': 'Pin model',
'ai.chat.unpinModel': 'Unpin model',
'ai.chat.loadingModels': 'Loading models...',
'ai.chat.modelCatalogSourceLive': 'Live catalog',
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
'ai.chat.noMatchingModels': 'No matching models',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
+14 -1
View File
@@ -44,6 +44,10 @@ export const enTerminalMessages: Messages = {
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'history.action.delete': 'Delete from history',
'history.action.clearAll': 'Clear all history',
'history.clearAll.dialog.title': 'Clear all command history?',
'history.clearAll.dialog.desc': 'This permanently deletes all locally recorded command history and autocomplete suggestions. This cannot be undone.',
'history.clearAll.dialog.confirm': 'Clear all',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
@@ -310,8 +314,11 @@ export const enTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Connecting...',
'cloudSync.provider.disconnect': 'Disconnect',
'cloudSync.provider.disconnect.confirmTitle': 'Disconnect "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Your local vault stays on this computer.',
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Its saved address and login credentials will be removed. Your local vault, backup history and sync encryption password stay here. If system credential storage is unavailable, plugin credentials may need manual removal.',
'cloudSync.provider.disconnect.confirmAction': 'Disconnect',
'cloudSync.provider.disconnect.toastTitle': 'Disconnected',
'cloudSync.provider.disconnect.cloudRemains': 'Encrypted sync copies may still exist in your {name} storage. To remove them, delete the sync files there. Disconnecting does not clear this device for handover.',
'cloudSync.provider.disconnect.failed': 'Could not finish disconnecting from {name}. Saved connection details may remain. Please try again.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
'cloudSync.provider.s3': 'S3 Compatible',
@@ -381,6 +388,12 @@ export const enTerminalMessages: Messages = {
'cloudSync.strategy.title': 'Sync strategy',
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
'cloudSync.aiSessions.title': 'Sync AI chat history',
'cloudSync.aiSessions.desc': 'Include AI chat sessions in cloud sync. Messages merge append-only across devices; attachment contents stay on this device.',
'cloudSync.aiSessions.compatibility': 'Older versions may overwrite chat history. Use an updated Netcatty on every synced device.',
'cloudSync.aiSessions.enableFailed': 'Could not update chat sync. Check the cloud connection and available local storage, then try again.',
'cloudSync.strategy.smartMergePreserve': 'Smart merge (never delete)',
'cloudSync.strategy.smartMergePreserveDesc': 'Combine changes from both sides and never propagate a one-sided deletion — an entry survives until it is removed on every device. Recommended when you restore backups or keep several machines in sync.',
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
'cloudSync.strategy.preferCloud': 'Cloud wins',
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
+5
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const esAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
// AI Settings
'ai.agentSettings': 'Configuración del agente',
'ai.chat.preparing': 'Preparando…',
@@ -504,6 +505,10 @@ export const esAiMessages: Messages = {
'ai.chat.pinModel': 'Fijar modelo',
'ai.chat.unpinModel': 'Quitar modelo fijado',
'ai.chat.loadingModels': 'Cargando modelos...',
'ai.chat.modelCatalogSourceLive': 'Catálogo en vivo',
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
+1
View File
@@ -883,6 +883,7 @@ Resalta el panel dividido enfocado:
'settings.shortcuts.binding.new-workspace': 'Nuevo espacio de trabajo',
'settings.shortcuts.binding.snippets': 'Abrir fragmentos',
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
+14 -1
View File
@@ -44,6 +44,10 @@ export const esTerminalMessages: Messages = {
'history.action.run': 'Ejecutar en la terminal',
'history.action.saveAsSnippet': 'Guardar como snippet',
'history.action.delete': 'Eliminar del historial',
'history.action.clearAll': 'Borrar todo el historial',
'history.clearAll.dialog.title': '¿Borrar todo el historial de comandos?',
'history.clearAll.dialog.desc': 'Esto elimina de forma permanente todo el historial de comandos local y las sugerencias de autocompletado. Esta acción no se puede deshacer.',
'history.clearAll.dialog.confirm': 'Borrar todo',
'terminal.toolbar.library': 'Biblioteca',
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
@@ -309,8 +313,11 @@ export const esTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Conectando...',
'cloudSync.provider.disconnect': 'Desconectar',
'cloudSync.provider.disconnect.confirmTitle': '¿Desconectar "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. La bóveda local permanecerá en este equipo.',
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. Se eliminarán su dirección y credenciales de acceso guardadas. La bóveda local, las copias de seguridad y la contraseña de cifrado de sincronización permanecerán aquí. Si el almacén de credenciales del sistema no está disponible, puede ser necesario eliminar manualmente las credenciales de los complementos.',
'cloudSync.provider.disconnect.confirmAction': 'Desconectar',
'cloudSync.provider.disconnect.toastTitle': 'Desconectado',
'cloudSync.provider.disconnect.cloudRemains': 'Las copias de sincronización cifradas pueden seguir en tu almacenamiento de {name}. Para eliminarlas, borra los archivos de sincronización allí. Desconectar no prepara este dispositivo para entregarlo a otra persona.',
'cloudSync.provider.disconnect.failed': 'No se pudo completar la desconexión de {name}. Los datos de conexión guardados podrían permanecer. Inténtalo de nuevo.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
'cloudSync.provider.s3': 'Compatible con S3',
@@ -380,6 +387,12 @@ export const esTerminalMessages: Messages = {
'cloudSync.strategy.title': 'Estrategia de sincronización',
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
'cloudSync.aiSessions.title': 'Sincronizar el historial de chats de IA',
'cloudSync.aiSessions.desc': 'Incluye las sesiones de chat de IA en la sincronización en la nube. Los mensajes se combinan de forma incremental entre dispositivos; el contenido de los adjuntos permanece en este dispositivo.',
'cloudSync.aiSessions.compatibility': 'Las versiones antiguas pueden sobrescribir el historial. Actualiza Netcatty en todos los dispositivos sincronizados.',
'cloudSync.aiSessions.enableFailed': 'No se pudo actualizar la sincronización del chat. Comprueba la conexión con la nube y el espacio local disponible e inténtalo de nuevo.',
'cloudSync.strategy.smartMergePreserve': 'Fusión inteligente (sin borrar)',
'cloudSync.strategy.smartMergePreserveDesc': 'Combina los cambios de ambos lados y nunca propaga un borrado de un solo lado: una entrada se conserva hasta que se elimina en todos los dispositivos. Recomendado al restaurar copias de seguridad o al usar varios equipos.',
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
'cloudSync.strategy.preferCloud': 'Gana la nube',
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
+5
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const ruAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
// AI Settings
'ai.agentSettings': 'Настройки агента',
@@ -460,6 +461,10 @@ export const ruAiMessages: Messages = {
'ai.chat.pinModel': 'Закрепить модель',
'ai.chat.unpinModel': 'Открепить модель',
'ai.chat.loadingModels': 'Загрузка моделей...',
'ai.chat.modelCatalogSourceLive': 'Актуальный список моделей',
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
+1
View File
@@ -908,6 +908,7 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
+14 -1
View File
@@ -65,6 +65,10 @@ export const ruTerminalMessages: Messages = {
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'history.action.delete': 'Удалить из истории',
'history.action.clearAll': 'Очистить всю историю',
'history.clearAll.dialog.title': 'Очистить всю историю команд?',
'history.clearAll.dialog.desc': 'Будет безвозвратно удалена вся локальная история команд и подсказки автодополнения. Это действие нельзя отменить.',
'history.clearAll.dialog.confirm': 'Очистить всё',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
@@ -328,8 +332,11 @@ export const ruTerminalMessages: Messages = {
'cloudSync.provider.connecting': 'Подключение...',
'cloudSync.provider.disconnect': 'Отключить',
'cloudSync.provider.disconnect.confirmTitle': 'Отключить "{name}"?',
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Локальное хранилище останется на этом компьютере.',
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Сохранённый адрес и данные для входа будут удалены. Локальное хранилище, резервные копии и пароль шифрования синхронизации останутся здесь. Если системное хранилище учётных данных недоступно, данные для входа плагинов может потребоваться удалить вручную.',
'cloudSync.provider.disconnect.confirmAction': 'Отключить',
'cloudSync.provider.disconnect.toastTitle': 'Отключено',
'cloudSync.provider.disconnect.cloudRemains': 'Зашифрованные копии синхронизации могут остаться в хранилище {name}. Чтобы удалить их, удалите там файлы синхронизации. Отключение не очищает устройство для передачи другому человеку.',
'cloudSync.provider.disconnect.failed': 'Не удалось завершить отключение от {name}. Сохранённые данные подключения могут остаться. Повторите попытку.',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
'cloudSync.provider.s3': 'Совместимое с S3',
@@ -399,6 +406,12 @@ export const ruTerminalMessages: Messages = {
'cloudSync.strategy.title': 'Стратегия синхронизации',
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
'cloudSync.aiSessions.title': 'Синхронизировать историю ИИ-чатов',
'cloudSync.aiSessions.desc': 'Включать сессии ИИ-чатов в облачную синхронизацию. Сообщения объединяются дополнением между устройствами; содержимое вложений остаётся на этом устройстве.',
'cloudSync.aiSessions.compatibility': 'Старые версии могут перезаписать историю. Обновите Netcatty на всех синхронизируемых устройствах.',
'cloudSync.aiSessions.enableFailed': 'Не удалось изменить синхронизацию чатов. Проверьте подключение к облаку и свободное место на устройстве, затем повторите попытку.',
'cloudSync.strategy.smartMergePreserve': 'Умное слияние (без удалений)',
'cloudSync.strategy.smartMergePreserveDesc': 'Объединяет изменения с обеих сторон и никогда не передаёт удаление с одной стороны: запись сохраняется, пока её не убрали на всех устройствах. Рекомендуется при восстановлении из резервных копий или при работе с несколькими машинами.',
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
'cloudSync.strategy.preferCloud': 'Приоритет облака',
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
+5
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
@@ -513,6 +514,10 @@ export const zhCNAiMessages: Messages = {
'ai.chat.pinModel': '固定模型',
'ai.chat.unpinModel': '取消固定',
'ai.chat.loadingModels': '正在加载模型...',
'ai.chat.modelCatalogSourceLive': '实时目录',
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
'ai.chat.noMatchingModels': '没有匹配的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
@@ -63,6 +63,10 @@ export const zhCNTerminalMessages: Messages = {
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
'history.action.delete': '从历史记录中删除',
'history.action.clearAll': '清空全部历史',
'history.clearAll.dialog.title': '清空全部命令历史?',
'history.clearAll.dialog.desc': '这将永久删除所有本地记录的命令历史及自动补全建议,且无法撤销。',
'history.clearAll.dialog.confirm': '全部清空',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -594,6 +598,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.new-workspace': '新建工作区',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
+10 -1
View File
@@ -717,8 +717,11 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.provider.connecting': '连接中...',
'cloudSync.provider.disconnect': '断开连接',
'cloudSync.provider.disconnect.confirmTitle': '断开 “{name}”?',
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步。本地保险库仍保留在这台电脑上。',
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步,并删除本机保存的该服务商地址与登录凭据。本地主机库、备份历史和同步加密密码仍会保留。若系统凭据存储不可用,插件凭据可能需要手动删除。',
'cloudSync.provider.disconnect.confirmAction': '断开连接',
'cloudSync.provider.disconnect.toastTitle': '已断开连接',
'cloudSync.provider.disconnect.cloudRemains': '云端的加密同步副本可能仍保存在 {name} 存储中。如需清除,请到该服务商删除同步文件。断开连接不代表设备已完成交接清理。',
'cloudSync.provider.disconnect.failed': '未能完成与 {name} 的断开连接,保存的连接资料可能仍在,请重试。',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
'cloudSync.provider.s3': 'S3 兼容存储',
@@ -788,6 +791,12 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.strategy.title': '同步策略',
'cloudSync.strategy.desc': '当本地和云端都发生变化时,选择如何处理。',
'cloudSync.strategy.smartMerge': '智能合并(推荐)',
'cloudSync.aiSessions.title': '同步 AI 对话记录',
'cloudSync.aiSessions.desc': '把 AI 对话记录纳入云同步。多设备的对话会按追加方式合并,附件内容仍保留在本机。',
'cloudSync.aiSessions.compatibility': '旧版本可能覆盖对话记录,请先更新所有参与同步的设备。',
'cloudSync.aiSessions.enableFailed': '无法更改聊天同步设置,请检查云端连接和本地可用空间,然后重试。',
'cloudSync.strategy.smartMergePreserve': '智能合并(不传播删除)',
'cloudSync.strategy.smartMergePreserveDesc': '合并两边的变化,且单侧删除不会同步到其它设备——只有所有设备都删掉才会真正消失。恢复备份或多台机器同步时推荐使用。',
'cloudSync.strategy.smartMergeDesc': '尽量保留两边的变化;如果无法安全判断,会再让你手动选择。',
'cloudSync.strategy.preferCloud': '云端优先',
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
+5
View File
@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const zhTWAiMessages: Messages = {
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
// AI Settings
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
@@ -503,6 +504,10 @@ export const zhTWAiMessages: Messages = {
'ai.chat.pinModel': '釘選模型',
'ai.chat.unpinModel': '取消釘選',
'ai.chat.loadingModels': '正在載入模型...',
'ai.chat.modelCatalogSourceLive': '即時目錄',
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
'ai.chat.noMatchingModels': '沒有符合的模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
@@ -63,6 +63,10 @@ export const zhTWTerminalMessages: Messages = {
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
'history.action.delete': '從歷史記錄中刪除',
'history.action.clearAll': '清空全部歷史',
'history.clearAll.dialog.title': '清空全部指令歷史?',
'history.clearAll.dialog.desc': '這將永久刪除所有本機記錄的指令歷史及自動補全建議,且無法復原。',
'history.clearAll.dialog.confirm': '全部清空',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
@@ -594,6 +598,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.binding.new-workspace': '新增工作區',
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
'settings.shortcuts.binding.broadcast': '切換廣播模式',
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
'settings.shortcuts.binding.sftp-copy': '複製檔案',
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
+10 -1
View File
@@ -717,8 +717,11 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.provider.connecting': '連線中...',
'cloudSync.provider.disconnect': '中斷連線',
'cloudSync.provider.disconnect.confirmTitle': '中斷「{name}」?',
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步。本機保險庫仍保留在這台電腦上。',
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步,並刪除本機保存的該服務商位址與登入憑證。本機主機庫、備份歷史和同步加密密碼仍會保留。若系統憑證儲存無法使用,外掛憑證可能需要手動刪除。',
'cloudSync.provider.disconnect.confirmAction': '中斷連線',
'cloudSync.provider.disconnect.toastTitle': '已中斷連線',
'cloudSync.provider.disconnect.cloudRemains': '雲端的加密同步副本可能仍保存在 {name} 儲存空間中。如需清除,請到該服務商刪除同步檔案。中斷連線不代表裝置已完成交接清理。',
'cloudSync.provider.disconnect.failed': '未能完成與 {name} 的中斷連線,保存的連線資料可能仍在,請重試。',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
'cloudSync.provider.s3': 'S3 相容儲存',
@@ -788,6 +791,12 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.strategy.title': '同步策略',
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
'cloudSync.aiSessions.title': '同步 AI 對話記錄',
'cloudSync.aiSessions.desc': '把 AI 對話記錄納入雲同步。多裝置的對話會按追加方式合併,附件內容仍保留在本機。',
'cloudSync.aiSessions.compatibility': '舊版本可能覆寫對話記錄,請先更新所有參與同步的裝置。',
'cloudSync.aiSessions.enableFailed': '無法變更聊天同步設定,請檢查雲端連線與本機可用空間,然後重試。',
'cloudSync.strategy.smartMergePreserve': '智慧合併(不傳播刪除)',
'cloudSync.strategy.smartMergePreserveDesc': '合併兩邊的變化,且單側刪除不會同步到其它裝置——只有所有裝置都刪掉才會真正消失。還原備份或多台機器同步時建議使用。',
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
'cloudSync.strategy.preferCloud': '雲端優先',
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
+8 -4
View File
@@ -2,6 +2,7 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
@@ -47,12 +48,10 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
export type DraftsByScope = Partial<Record<string, AIDraft>>;
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
export function cleanupSdkAgentSessions(sessionIds: string[]) {
export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<void> {
const bridge = getAIBridge();
if (sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
}
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
}
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
@@ -471,6 +470,11 @@ export function serializeSessionsForStorage(
* could not be persisted even after escalation (it stays memory-only).
*/
export function writeSessionsForStorage(sessions: AISession[]): boolean {
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
}
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
// A real quota failure means the next attempt must be smaller even when
@@ -0,0 +1,328 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
STORAGE_KEY_COMMAND_HISTORY,
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
} from "../../infrastructure/config/storageKeys";
type StorageEventLike = { key: string | null; newValue: string | null; oldValue: string | null };
type StorageListener = (event: StorageEventLike) => void;
type DeferredRead = { resolve: (value: string) => void };
const COMMAND_HISTORY_STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
function installLocalStorage(): Map<string, string> {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
},
configurable: true,
});
return store;
}
/**
* The store registers its cross-window `storage` listener at module load, so
* the DOM globals must exist before the dynamic imports below.
*/
function installWindow(): (key: string, newValue: string | null, oldValue?: string | null) => void {
const listeners = new Set<StorageListener>();
Object.defineProperty(globalThis, "window", {
value: {
addEventListener: (type: string, listener: StorageListener) => {
if (type === "storage") listeners.add(listener);
},
removeEventListener: (type: string, listener: StorageListener) => {
if (type === "storage") listeners.delete(listener);
},
netcatty: undefined as unknown,
},
configurable: true,
});
return (key: string, newValue: string | null, oldValue: string | null = null) => {
for (const listener of listeners) listener({ key, newValue, oldValue });
};
}
async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
const storage = installLocalStorage();
const dispatchStorageEvent = installWindow();
const {
clearHistory,
flushCommandHistoryStore,
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
markCommandHistoryCleared,
queryHistory,
recordCommand,
subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts");
const { seedLocalShellHistoryFromHistfiles } = await import(
"../../components/terminal/autocomplete/localShellHistorySeed.ts"
);
function readPersistedCommands(): string[] {
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as { entries: Array<{ command: string }> };
return parsed.entries.map((entry) => entry.command);
}
function readPersistedGeneration(): number {
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
if (!raw) return 0;
const parsed = JSON.parse(raw) as { clearGeneration?: number };
return typeof parsed.clearGeneration === "number" ? parsed.clearGeneration : 0;
}
test("a fresh popup keeps compose recall on an ordinary peer write after an earlier clear", async () => {
const baseline = JSON.stringify({ entries: [], version: 1, clearGeneration: 3 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, baseline);
const composeBar = await import("./composeBarHistoryStore.ts");
composeBar.recordComposeBarHistory("fresh-popup", "echo popup-current-command");
// No autocomplete query has loaded this renderer's command-history store.
const recorded = JSON.stringify({
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
version: 1, clearGeneration: 3,
});
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded, baseline);
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), ["echo popup-current-command"]);
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 4 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared, recorded);
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), []);
});
test("a stale pending write cannot overwrite a peer clear before its storage event arrives", () => {
clearHistory();
recordCommand("old-command", "host-1");
flushCommandHistoryStore();
recordCommand("pending-before-clear", "host-1");
// localStorage is already updated in the peer, but this renderer has not yet
// processed its queued storage event when the pending save runs.
const generation = readPersistedGeneration() + 1;
storage.set(COMMAND_HISTORY_STORAGE_KEY, JSON.stringify({
entries: [], version: 1, clearGeneration: generation,
}));
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), []);
assert.equal(readPersistedGeneration(), generation);
assert.deepEqual(queryHistory("old", { hostId: "host-1" }), []);
recordCommand("new-after-clear", "host-1");
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), ["new-after-clear"]);
});
test("an ordinary peer write does not cancel a locally pending command", async () => {
clearHistory();
recordCommand("local-pending-command", "host-1");
const before = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
const ordinary = JSON.stringify({
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
version: 1, clearGeneration: getCommandHistoryClearGeneration(),
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, ordinary);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, ordinary, before);
await new Promise((resolve) => setTimeout(resolve, 600));
assert.ok(readPersistedCommands().includes("local-pending-command"));
});
test("an external clear invalidates the cached history and cannot be resurrected", () => {
storage.clear();
clearHistory();
recordCommand("stale-command --flag", "host-1");
flushCommandHistoryStore();
assert.ok(queryHistory("stale", { hostId: "host-1" }).length > 0);
// Simulate another renderer (main window or a detached terminal popup)
// clearing the shared localStorage entry.
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 1 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
// The stale module-local cache must be dropped immediately.
assert.equal(queryHistory("stale", { hostId: "host-1" }).length, 0);
// A later recordCommand must persist only the new command, never the stale
// snapshot it still held in memory.
recordCommand("fresh-command", "host-1");
flushCommandHistoryStore();
assert.deepEqual(readPersistedCommands(), ["fresh-command"]);
});
test("clearHistory persists synchronously and cancels a pending debounced write", async () => {
storage.clear();
clearHistory();
recordCommand("pending-command", "host-1");
// No flush: the debounced write is still scheduled.
clearHistory();
assert.deepEqual(readPersistedCommands(), []);
// Wait past the debounce window to prove the cancelled write cannot restore
// the entries.
await new Promise((resolve) => setTimeout(resolve, 600));
assert.deepEqual(readPersistedCommands(), []);
});
test("a local clear bumps the generation and notifies subscribers", () => {
storage.clear();
clearHistory();
const before = getCommandHistoryClearGeneration();
let notified = 0;
const unsubscribe = subscribeCommandHistoryCleared(() => {
notified += 1;
});
recordCommand("notify-local", "host-1");
clearHistory();
assert.equal(getCommandHistoryClearGeneration(), before + 1);
assert.equal(readPersistedGeneration(), before + 1);
assert.equal(notified, 1);
unsubscribe();
clearHistory();
assert.equal(notified, 1);
});
test("an external clear notifies subscribers, while an external record does not", () => {
storage.clear();
clearHistory();
const baseline = getCommandHistoryClearGeneration();
let notified = 0;
const unsubscribe = subscribeCommandHistoryCleared(() => {
notified += 1;
});
// A normal record from another renderer keeps the generation: no clear event.
const recorded = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline });
storage.set(COMMAND_HISTORY_STORAGE_KEY, recorded);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded);
assert.equal(notified, 0);
// A clear from another renderer bumps the generation: subscribers fire.
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline + 1 });
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
assert.equal(notified, 1);
assert.equal(getCommandHistoryClearGeneration(), baseline + 1);
unsubscribe();
});
test("a histfile import that started before a clear is discarded", async () => {
storage.clear();
clearHistory();
const hostId = `local-seed-clear-${Date.now()}`;
const deferredReads: DeferredRead[] = [];
(window as unknown as { netcatty?: unknown }).netcatty = {
getHomeDir: async () => "/home/demo",
readLocalFile: () =>
new Promise<string>((resolve) => {
deferredReads.push({ resolve });
}),
};
const seedPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
// Let the seeder resolve getHomeDir and reach the histfile reads.
await waitFor(() => deferredReads.length === 4);
// The user clears history while the bridge reads are still pending.
clearHistory();
for (const deferred of deferredReads) {
deferred.resolve(": 1700000000:0;echo seeded\n");
}
assert.equal(await seedPromise, 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
// The host is marked seeded so a later open cannot re-import the deleted
// commands.
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
});
test("markCommandHistoryCleared persists a tombstone for histfile seeding", () => {
storage.clear();
assert.equal(hasCommandHistoryBeenCleared(), false);
markCommandHistoryCleared();
assert.equal(hasCommandHistoryBeenCleared(), true);
assert.equal(storage.get(STORAGE_KEY_COMMAND_HISTORY_CLEARED), "true");
});
test("a first-time histfile import is skipped once history has been cleared", async () => {
storage.clear();
clearHistory();
markCommandHistoryCleared();
const hostId = `local-seed-tombstone-${Date.now()}`;
(window as unknown as { netcatty?: unknown }).netcatty = {
getHomeDir: async () => "/home/demo",
readLocalFile: async () => ": 1700000000:0;echo resurrected\n",
};
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
});
test("an external clear also drops in-memory compose-bar recall", async () => {
const composeBar = await import("./composeBarHistoryStore.ts");
storage.clear();
clearHistory();
composeBar.pruneComposeBarHistory([]);
composeBar.recordComposeBarHistory("session-1", "echo compose");
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), ["echo compose"]);
let notified = 0;
const unsubscribe = composeBar.subscribeComposeBarHistoryCleared(() => {
notified += 1;
});
// Simulate another renderer clearing command history. The compose-bar store
// follows the same clear event, so a detached popup drops its recall too.
const cleared = JSON.stringify({
entries: [],
version: 1,
clearGeneration: getCommandHistoryClearGeneration() + 1,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), []);
assert.equal(notified, 1);
unsubscribe();
});
@@ -2,11 +2,19 @@
* Persistent command history store for terminal autocomplete.
* Stores commands per host with frequency tracking and timestamp ordering.
* Uses localStorageAdapter as the persistence layer (works in renderer process).
*
* Lives in application state rather than the UI layer so the vault hook and the
* terminal UI depend on one lower-layer API, and so every renderer (main window
* and detached terminal popups) shares the same clear semantics.
*/
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
STORAGE_KEY_COMMAND_HISTORY,
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
} from "../../infrastructure/config/storageKeys";
const STORAGE_KEY = "netcatty:commandHistory";
const STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
const MAX_ENTRIES = 10000;
const MAX_ENTRIES_PER_HOST = 5000;
@@ -26,9 +34,21 @@ export interface HistoryEntry {
interface HistoryStore {
entries: HistoryEntry[];
version: number;
/**
* Monotonic counter bumped by every global clear. Other renderers compare it
* to detect a clear performed elsewhere, and in-flight histfile imports use
* it to discard work that started before the clear.
*/
clearGeneration?: number;
}
let cachedStore: HistoryStore | null = null;
/** Last clear generation this renderer has observed. */
let knownClearGeneration: number | null = null;
function readClearGeneration(store: HistoryStore): number {
return typeof store.clearGeneration === "number" ? store.clearGeneration : 0;
}
function loadStore(): HistoryStore {
if (cachedStore) return cachedStore;
@@ -36,18 +56,29 @@ function loadStore(): HistoryStore {
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (parsed) {
cachedStore = parsed;
knownClearGeneration = readClearGeneration(parsed);
return parsed;
}
} catch {
// Corrupted data, reset
}
cachedStore = { entries: [], version: 1 };
cachedStore = { entries: [], version: 1, clearGeneration: 0 };
knownClearGeneration = 0;
return cachedStore;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function persistStoreNow(store: HistoryStore): boolean {
// A peer clear is visible in storage before its queued event is handled.
// Never flush a cached pre-clear snapshot over that newer empty store.
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion)
@@ -67,6 +98,13 @@ function saveStore(store: HistoryStore): void {
}, 500);
}
function cancelPendingSave(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
/**
* Flush any pending debounced history write immediately.
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
@@ -75,13 +113,112 @@ function saveStore(store: HistoryStore): void {
*/
export function flushCommandHistoryStore(): boolean {
if (!cachedStore) return true;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
cancelPendingSave();
return persistStoreNow(cachedStore);
}
/**
* React to a write performed by another renderer (main window or a detached
* terminal popup).
*
* A bumped clear generation drops the cache and pending save, then notifies
* live autocomplete controllers. Ordinary peer writes must not cancel a local
* command that is still waiting for its debounced save.
*/
function applyExternalCommandHistoryWrite(
rawNewValue: string | null,
rawOldValue: string | null,
): void {
let incomingGeneration: number | null = null;
if (rawNewValue) {
try {
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
} catch {
incomingGeneration = null;
}
}
let previousGeneration = knownClearGeneration;
if (previousGeneration === null) {
// A fresh popup can have compose recall before its first autocomplete query.
// The event's old value is its baseline, even after an earlier global clear.
try {
previousGeneration = rawOldValue
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
: 0;
} catch {
previousGeneration = 0;
}
}
if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
cancelPendingSave();
cachedStore = null;
knownClearGeneration = incomingGeneration;
notifyCommandHistoryCleared();
} else {
if (!saveTimer) cachedStore = null;
if (incomingGeneration !== null && knownClearGeneration === null) {
knownClearGeneration = incomingGeneration;
}
}
}
// The native `storage` event fires in *other* same-origin windows (main window
// and detached terminal popups) when a key changes there.
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY) applyExternalCommandHistoryWrite(event.newValue, event.oldValue);
});
}
/**
* Current clear generation. Callers capture this before async work and compare
* afterwards to detect a clear that happened while they were waiting.
*/
export function getCommandHistoryClearGeneration(): number {
if (knownClearGeneration === null) loadStore();
return knownClearGeneration ?? 0;
}
/**
* Record that the user explicitly cleared all command history.
*
* Histfile seeding honors this tombstone: a clear is a user decision to delete
* locally recorded history, so a later first-time import must not restore the
* commands the user deleted (a pre-clear in-flight check alone cannot catch a
* clear that happened before the import started).
*/
export function markCommandHistoryCleared(): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED, true);
}
/** True once the user has cleared all command history at least once. */
export function hasCommandHistoryBeenCleared(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_COMMAND_HISTORY_CLEARED) === true;
}
type CommandHistoryClearedListener = () => void;
const clearedListeners = new Set<CommandHistoryClearedListener>();
/**
* Subscribe to command-history clears, including clears performed in another
* renderer. Live autocomplete controllers use this to drop visible suggestions
* and ghost text and abort in-flight completions, so a stale row cannot be
* accepted and recorded after the history was cleared.
*/
export function subscribeCommandHistoryCleared(
listener: CommandHistoryClearedListener,
): () => void {
clearedListeners.add(listener);
return () => {
clearedListeners.delete(listener);
};
}
function notifyCommandHistoryCleared(): void {
for (const listener of [...clearedListeners]) listener();
}
/**
* Record a command execution. Updates frequency if the command already exists
* for this host, otherwise creates a new entry.
@@ -149,10 +286,7 @@ export function removeCommandHistoryEntry(command: string, hostId: string): bool
if (nextEntries.length === store.entries.length) return false;
store.entries = nextEntries;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
cancelPendingSave();
return persistStoreNow(store);
}
@@ -427,6 +561,8 @@ function fuzzyScore(query: string, target: string): number {
/**
* Clear all history for a specific host, or all history if no hostId given.
* A global clear also bumps the shared clear generation so other renderers and
* in-flight histfile imports can detect it.
*/
export function clearHistory(hostId?: string): void {
const store = loadStore();
@@ -434,6 +570,12 @@ export function clearHistory(hostId?: string): void {
store.entries = store.entries.filter((e) => e.hostId !== hostId);
} else {
store.entries = [];
store.clearGeneration = readClearGeneration(store) + 1;
}
saveStore(store);
// Destructive clears must be durable: cancel any pending debounced write and
// persist synchronously so a later stale flush cannot restore the entries.
cancelPendingSave();
persistStoreNow(store);
knownClearGeneration = readClearGeneration(store);
if (!hostId) notifyCommandHistoryCleared();
}
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory, clearComposeBarHistory, subscribeComposeBarHistoryCleared } from './composeBarHistoryStore';
test('compose history is isolated per terminal and removed when its session closes', () => {
pruneComposeBarHistory([]);
@@ -40,3 +40,24 @@ test('failed submissions unblock later successes in submission order', async ()
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
pruneComposeBarHistory([]);
});
test('clearComposeBarHistory drops every session and notifies live compose bars', () => {
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'echo a');
recordComposeBarHistory('b', 'echo b');
let notified = 0;
const unsubscribe = subscribeComposeBarHistoryCleared(() => {
notified += 1;
});
clearComposeBarHistory();
assert.deepEqual(getComposeBarHistory('a'), []);
assert.deepEqual(getComposeBarHistory('b'), []);
assert.equal(notified, 1);
unsubscribe();
clearComposeBarHistory();
assert.equal(notified, 1);
});
@@ -1,10 +1,14 @@
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
import { subscribeCommandHistoryCleared } from './commandHistoryStore';
// Session-only memory: survives closing the bar and moving a terminal into a
// workspace, but is discarded when that terminal session is removed.
type PendingSend = { settled: boolean; command?: string };
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
type Listener = () => void;
const clearedListeners = new Set<Listener>();
export function getComposeBarHistory(sessionId: string): readonly string[] {
return histories.get(sessionId)?.entries ?? [];
}
@@ -44,3 +48,36 @@ export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
if (!active.has(id)) histories.delete(id);
}
}
/**
* Subscribe to full compose-bar history clears. A live compose bar uses this to
* drop the history walk it cached, so ArrowUp cannot recall deleted commands.
*/
export function subscribeComposeBarHistoryCleared(listener: Listener): () => void {
clearedListeners.add(listener);
return () => {
clearedListeners.delete(listener);
};
}
/**
* Clear the in-memory compose-bar history for every session.
*
* This store is session-only (never persisted), so the "clear all command
* history" action must clear it explicitly. Otherwise a command sent through
* the compose bar stays recallable, and resending it would re-record it into
* shell history and autocomplete after the user deleted it.
*/
export function clearComposeBarHistory(): void {
histories.clear();
// Always notify: a live compose bar can still hold a cached walk even when
// the map is already empty (for example after its session was pruned).
for (const listener of [...clearedListeners]) listener();
}
// The compose bar holds the most recent commands sent through it, so clearing
// command history must drop it too. Subscribing to the command-history clear
// event covers every renderer: a detached terminal popup renders its own
// TerminalComposeBar and learns about a main-window clear through the shared
// clear generation, while the main window is notified synchronously.
subscribeCommandHistoryCleared(clearComposeBarHistory);
@@ -148,7 +148,8 @@ test("session peer windows do not run main-window startup effects", () => {
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
// Detached windows share opacity, while general settings broadcasts stay off.
assert.match(settingsStateSource, /if \(!enableSettingsSync && key !== STORAGE_KEY_WINDOW_OPACITY\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
@@ -133,6 +133,11 @@ export type OpenTransferSftpSessionOptions = {
* credentials. When false, try sourceSessionId first (shared transport).
*/
dedicated?: boolean;
/**
* Connect-time jump-host snapshots. When set, this session dials through the
* pinned chain the pane connected with, not the live (possibly edited) chain.
*/
jumpHosts?: Host[];
};
/**
@@ -159,6 +164,7 @@ export async function openTransferSftpSession(
identities: [...deps.identities],
knownHosts: deps.knownHosts ? [...deps.knownHosts] : undefined,
terminalSettings: deps.terminalSettings,
jumpHostsOverride: options?.jumpHosts,
});
// Shared transport path: open an SFTP channel on the already-authenticated
// terminal connection. The transport registry lease keeps the conn alive
+25 -2
View File
@@ -137,11 +137,26 @@ async function tryStatTransferTarget(
return tryStatTransferPath(targetPath, targetIsLocal, targetSftpId, targetEncoding);
}
export interface TransferConnectHosts {
source?: Host;
target?: Host;
/**
* Connect-time jump-host chain snapshots. Pinned together with the top-level
* host so leases opened after a bastion edit still dial through the chain the
* pane's session connected with (private hostnames can resolve differently
* per bastion). Present only when the matching endpoint is pinned.
*/
sourceChain?: Host[];
targetChain?: Host[];
}
export type AcquireTransferSessionFn = (
hostId: string,
transferId: string,
/** Connect-time host (session hostname/port/user overrides). Prefer over vault. */
connectHost?: Host,
/** Connect-time jump-host snapshots; undefined re-resolves the live chain. */
jumpHosts?: Host[],
) => Promise<TransferConnectionLease>;
interface UseSftpDirectoryTransferOpsParams {
@@ -268,6 +283,7 @@ export function useSftpDirectoryTransferOps({
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
connectHosts?: TransferConnectHosts,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -315,10 +331,14 @@ export function useSftpDirectoryTransferOps({
// Dedicated pool only for remote ends — panel/browse sessions die when
// the SFTP tab is closed, which freezes global transfer center rows.
if (acquireTransferSession && !sourceIsLocal && task.sourceHostId) {
sourceLease = await acquireTransferSession(task.sourceHostId, task.id);
sourceLease = await acquireTransferSession(
task.sourceHostId, task.id, connectHosts?.source, connectHosts?.sourceChain,
);
}
if (acquireTransferSession && !targetIsLocal && task.targetHostId) {
targetLease = await acquireTransferSession(task.targetHostId, task.id);
targetLease = await acquireTransferSession(
task.targetHostId, task.id, connectHosts?.target, connectHosts?.targetChain,
);
}
};
@@ -541,6 +561,7 @@ export function useSftpDirectoryTransferOps({
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
discoveryProgress?: DirectoryDiscoveryProgress,
traversalBudget?: SftpDirectoryTraversalBudget,
connectHosts?: TransferConnectHosts,
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -698,6 +719,7 @@ export function useSftpDirectoryTransferOps({
followSymlinks,
progress,
traversal,
connectHosts,
);
}
} finally {
@@ -925,6 +947,7 @@ export function useSftpDirectoryTransferOps({
targetEncoding,
rootTaskId,
sameHost,
connectHosts,
);
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
@@ -42,6 +42,12 @@ interface UseSftpConnectionsParams {
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
connectedHostByTabIdRef: MutableRefObject<Map<string, Host | "local">>;
/**
* Jump-host chain snapshots captured when each tab connected, keyed by tab
* id. Same lifecycle as connectedHostByTabIdRef; used to keep transfer
* session opens on the pane's connect-time bastion chain after chain edits.
*/
connectedHostChainByTabIdRef?: MutableRefObject<Map<string, Host[]>>;
connectInFlightRef: MutableRefObject<Map<string, Promise<void>>>;
connectRequestByTabIdRef: MutableRefObject<Map<string, symbol>>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
@@ -415,12 +421,15 @@ export async function closeSftpTabLifecycle(params: {
connectRequests: Map<string, symbol>;
connectInFlight: Map<string, Promise<void>>;
connectedHosts: { delete: (tabId: string) => boolean };
/** Connect-time jump-host chain snapshots, same tab-id lifecycle. */
connectedHostChains?: { delete: (tabId: string) => boolean };
closeTab: (side: "left" | "right") => void;
releaseConnection: () => Promise<void>;
}): Promise<void> {
invalidateSftpTabConnectRequest(params.connectRequests, params.tabId);
clearSftpConnectInFlightForTab(params.connectInFlight, params.tabId);
params.connectedHosts.delete(params.tabId);
params.connectedHostChains?.delete(params.tabId);
applyToLiveSftpTabSide({
requestedSide: params.requestedSide,
tabId: params.tabId,
@@ -581,6 +590,7 @@ export const useSftpConnections = ({
lastConnectedHostRef,
connectionCacheKeyMapRef,
connectedHostByTabIdRef,
connectedHostChainByTabIdRef,
connectInFlightRef,
connectRequestByTabIdRef,
makeCacheKey,
@@ -863,6 +873,24 @@ export const useSftpConnections = ({
// Always remember the full connect-time Host for this tab so background
// upload reconnects can restore session-time overrides (not just vault hostId).
connectedHostByTabIdRef.current.set(activeTabId, host);
// Snapshot the resolved jump-host chain so retained transfer leases keep
// dialing the chain this tab connected through, even after one of the
// bastion hosts is edited (private hostnames can resolve differently
// per bastion — the live chain must not silently replace this one).
if (connectedHostChainByTabIdRef) {
if (host !== "local" && host.hostChain?.hostIds?.length) {
const chain = host.hostChain.hostIds
.map((chainId) => hosts.find((candidate) => candidate.id === chainId))
.filter((candidate): candidate is Host => !!candidate);
if (chain.length === host.hostChain.hostIds.length) {
connectedHostChainByTabIdRef.current.set(activeTabId, chain);
} else {
connectedHostChainByTabIdRef.current.delete(activeTabId);
}
} else {
connectedHostChainByTabIdRef.current.delete(activeTabId);
}
}
// Store the cache key for this connection so pane actions can look it up
// by connectionId instead of relying on the per-side lastConnectedHostRef.
if (host !== "local") {
@@ -1426,6 +1454,7 @@ export const useSftpConnections = ({
lastConnectedHostRef.current[side] = null;
connectedHostByTabIdRef.current.delete(activeTabId);
connectedHostChainByTabIdRef?.current.delete(activeTabId);
if (pane.connection && !pane.connection.isLocal && sftpId) {
try {
@@ -148,6 +148,7 @@ export const useSftpExternalOperations = (
connectionCacheKeyMapRef,
ensureRemoteSftpId,
resolveConnectedHost,
resolveConnectedChain,
acquireTransferSession,
clearDirCacheEntry,
useCompressedUpload = false,
@@ -165,6 +166,15 @@ export const useSftpExternalOperations = (
[resolveConnectedHost],
);
/** Jump-host chain the tab dialed at connect time, when still meaningful. */
const resolveUploadConnectChain = useCallback(
(tabId: string, isLocal: boolean): Host[] | undefined => {
if (isLocal || !resolveConnectedChain) return undefined;
return resolveConnectedChain(tabId) ?? undefined;
},
[resolveConnectedChain],
);
/**
* Resolve an SFTP id for upload prep (mkdir/stat/conflict checks).
* File bytes use dedicated pool connections inside startStreamTransfer
@@ -843,6 +853,7 @@ export const useSftpExternalOperations = (
// (session hostname/port/user overrides), not the vault entry by hostId alone.
const createUploadBridge = useCallback((
connectHost?: Host,
connectChain?: Host[],
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
): UploadBridge => {
const bridge = netcattyBridge.get();
@@ -920,6 +931,7 @@ export const useSftpExternalOperations = (
options.targetHostId,
options.transferId,
connectHost,
connectChain,
);
}
@@ -1042,8 +1054,10 @@ export const useSftpExternalOperations = (
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(
connectHost,
connectChain,
readSftpFileTransferConcurrency,
);
const liveCallbacks = livePane.connection.id === pane.connection.id
@@ -1209,7 +1223,8 @@ export const useSftpExternalOperations = (
const uploadPaneId = livePane.id;
const liveTargetPath = targetPath || livePane.connection.currentPath;
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost, connectChain);
// Retarget callbacks if the pane path/id changed after reconnect.
const liveCallbacks = livePane.connection.id === pane.connection.id
@@ -1252,7 +1267,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
@@ -1331,6 +1346,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -1367,7 +1383,8 @@ export const useSftpExternalOperations = (
),
);
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const uploadBridge = createUploadBridge(connectHost, connectChain);
try {
const files = Array.from(fileList);
@@ -1382,7 +1399,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadFromFileList(
@@ -1441,6 +1458,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -1557,7 +1575,11 @@ export const useSftpExternalOperations = (
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost);
const uploadConnectChain = resolveUploadConnectChain(
uploadPaneId,
livePane.connection.isLocal,
);
const uploadBridge = createUploadBridge(uploadConnectHost, uploadConnectChain);
const liveCallbacks = bindUploadControllerCallbacks(
controller,
createUploadCallbacks(
@@ -1603,7 +1625,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost, uploadConnectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
@@ -1687,6 +1709,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -1775,7 +1798,8 @@ export const useSftpExternalOperations = (
),
);
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
const directUploadBridge = createUploadBridge(connectHost);
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
const directUploadBridge = createUploadBridge(connectHost, connectChain);
try {
const hasDirectory = entries.some((entry) => (
@@ -1789,7 +1813,7 @@ export const useSftpExternalOperations = (
jobId: `compressed-upload-${crypto.randomUUID()}`,
prepSftpId: sftpId,
acquire: acquireTransferSession
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
: undefined,
shouldDiscard: isSessionError,
run: async (uploadSftpId) => uploadEntriesDirect(
@@ -1856,6 +1880,7 @@ export const useSftpExternalOperations = (
refresh,
resolveRemoteSftpId,
resolveUploadConnectHost,
resolveUploadConnectChain,
useCompressedUpload,
],
);
@@ -32,6 +32,12 @@ export interface UseSftpExternalOperationsParams {
* Used so pooled stream uploads open the pinned browse endpoint.
*/
resolveConnectedHost?: (tabId: string) => Host | "local" | null | undefined;
/**
* Jump-host chain snapshots captured when the tab connected, keyed by tab id.
* Lets pooled uploads keep dialing the pane's connect-time bastion chain
* after a chain host is edited.
*/
resolveConnectedChain?: (tabId: string) => Host[] | null | undefined;
/**
* FileZilla-style dedicated transfer sessions for bulk uploads.
* When set, remote stream uploads prefer pool connections (1–2/host)
@@ -41,6 +47,7 @@ export interface UseSftpExternalOperationsParams {
hostId: string,
transferId: string,
connectHost?: Host,
connectChain?: Host[],
) => Promise<{ sftpId: string; release: () => void; discard: () => void }>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
@@ -707,3 +707,30 @@ test("imported IdentityFile paths remain usable when agent login is disabled", (
assert.equal(credentials.useSshAgent, false);
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
});
test("buildSftpHostCredentials pins the captured jump chain over edited vault hosts", () => {
const oldBastion = host({ id: "jump-1", hostname: "bastion-internal.old" });
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [editedBastion],
keys: [],
identities: [],
jumpHostsOverride: [oldBastion],
});
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.old");
assert.equal(credentials.jumpHosts?.[0]?.hostId, "jump-1");
});
test("buildSftpHostCredentials re-resolves the live chain without a pinned snapshot", () => {
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [editedBastion],
keys: [],
identities: [],
});
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.edited");
});
@@ -38,7 +38,17 @@ export const buildSftpHostCredentials = ({
identities,
knownHosts,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
jumpHostsOverride,
}: UseSftpHostCredentialsParams & {
host: Host;
/**
* Connect-time jump-host snapshots. When set, the chain resolves from these
* pinned Host objects instead of the live `hosts` array, so leases opened
* after a bastion edit keep connecting through the chain the pane's session
* dialed — private hostnames can resolve differently per bastion.
*/
jumpHostsOverride?: Host[];
}): NetcattySSHOptions => {
const globalTerminalSettings = { ...FALLBACK_TERMINAL_SETTINGS, ...(terminalSettings ?? {}) };
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
@@ -57,12 +67,21 @@ export const buildSftpHostCredentials = ({
? resolveProxyConfigAuth(host.proxyConfig, identities)
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds.map((hostId) => {
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
if (!jumpHost) {
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
}
const resolveChainHost = (hostId: string): Host => {
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
if (!jumpHost) {
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
}
return jumpHost;
};
// A non-empty pinned chain snapshot wins over live hostChain resolution.
const chainHosts: Host[] | null = jumpHostsOverride && jumpHostsOverride.length > 0
? jumpHostsOverride
: host.hostChain?.hostIds && host.hostChain.hostIds.length > 0
? host.hostChain.hostIds.map(resolveChainHost)
: null;
if (chainHosts) {
jumpHosts = chainHosts.map((jumpHost, index) => {
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
@@ -72,8 +91,6 @@ export const buildSftpHostCredentials = ({
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
@@ -5,6 +5,7 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { Host } from "../../../domain/models";
import type { SftpPane } from "./types";
type StartOptions = {
@@ -83,6 +84,160 @@ function installGlobals(netty: Record<string, unknown>) {
};
}
for (const pinned of [false, true]) {
for (const direction of ["download", "upload"] as const) {
test(`${pinned ? "existing" : "new"} dual-pane ${direction} preserves its endpoint after host edits`, async () => {
const acquired: string[] = [];
const started: StartOptions[] = [];
const restore = installGlobals({
statSftp: async () => direction === "download"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
statLocal: async () => direction === "upload"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: options.transferId,
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
});
return {};
},
});
// These pane callbacks stay stable in the app while the saved hosts change.
const getActivePane = (side: "left" | "right") => makePane(side);
const getPaneByConnectionId = () => null;
const getTabByConnectionId = () => null;
const updateTab = () => undefined;
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
const pinnedHost = { id: "host-1", hostname: "old-endpoint" } as Host;
const pinnedChain = [{ id: "jump-1", hostname: "old-bastion" } as Host];
const acquiredChains: string[] = [];
const acquire = (savedEndpoint: string) => async (
_hostId: string, _jobId: string, connectHost?: Host, connectChain?: Host[],
) => {
const endpoint = connectHost?.hostname ?? savedEndpoint;
acquired.push(endpoint);
acquiredChains.push(connectChain?.map((hop) => hop.hostname).join(" -> ") ?? "unpinned");
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
};
const original = acquire("old-endpoint");
const refreshed = acquire("new-endpoint");
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe({ acquireSession }: { acquireSession: typeof original }) {
ops = useSftpTransfers({
ownerId: `refresh-host-${direction}`,
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
refresh: async () => undefined, clearCacheForConnection: () => undefined,
handleSessionError: () => undefined, sftpSessionsRef,
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => [],
acquireTransferSession: acquireSession,
resolveConnectedHost: () => pinned ? pinnedHost : null,
resolveConnectedChain: pinned ? () => pinnedChain : undefined,
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe, { acquireSession: original })); });
await act(async () => { renderer!.update(React.createElement(Probe, { acquireSession: refreshed })); });
await act(async () => {
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
});
assert.equal(acquired.length, 2, "both the work lease and file lease must use the pane endpoint");
assert.ok(acquired.every((endpoint) => endpoint === (pinned ? "old-endpoint" : "new-endpoint")), JSON.stringify(acquired));
if (pinned) {
assert.ok(
acquiredChains.every((chain) => chain === "old-bastion"),
`the pinned jump chain must ride along with the pinned endpoint: ${JSON.stringify(acquiredChains)}`,
);
} else {
assert.ok(
acquiredChains.every((chain) => chain === "unpinned"),
`no chain may attach when the connect host is unresolved: ${JSON.stringify(acquiredChains)}`,
);
}
assert.equal(started.length, 1);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
}
for (const direction of ["download", "upload"] as const) {
test(`dual-pane ${direction} started during a reconnect falls back to the pane endpoint, not the incoming tab host`, async () => {
const acquired: string[] = [];
const started: StartOptions[] = [];
const restore = installGlobals({
statSftp: async () => direction === "download"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
statLocal: async () => direction === "upload"
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
: null,
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: options.transferId,
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
});
return {};
},
});
const getActivePane = (side: "left" | "right") => makePane(side);
const getPaneByConnectionId = () => null;
const getTabByConnectionId = () => null;
const updateTab = () => undefined;
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
// connect() has already recorded the incoming endpoint for this tab id
// while the pane still shows the old connection (reconnect in flight).
const incomingHost = { id: "host-2", hostname: "reconnect-endpoint" } as Host;
const acquire = (savedEndpoint: string) => async (_hostId: string, _jobId: string, connectHost?: Host) => {
const endpoint = connectHost?.hostname ?? savedEndpoint;
acquired.push(endpoint);
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
};
const acquireSession = acquire("saved-endpoint");
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: `reconnect-host-${direction}`,
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
refresh: async () => undefined, clearCacheForConnection: () => undefined,
handleSessionError: () => undefined, sftpSessionsRef,
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [], listRemoteFiles: async () => [],
acquireTransferSession: acquireSession,
resolveConnectedHost: () => incomingHost,
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
});
assert.equal(acquired.length, 2, "both the work lease and file lease must be acquired");
assert.ok(
acquired.every((endpoint) => endpoint === "saved-endpoint"),
`the incoming endpoint must not capture the transfer: ${JSON.stringify(acquired)}`,
);
assert.equal(started.length, 1);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
for (const liveStatAvailable of [true, false]) {
test(
liveStatAvailable
+131 -8
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction
import {
FileConflict,
FileConflictAction,
Host,
SftpFilenameEncoding,
TransferDirection,
TransferStatus,
@@ -25,7 +26,10 @@ import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types";
import { isMissingStatError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import {
useSftpDirectoryTransferOps,
type TransferConnectHosts,
} from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
@@ -78,6 +82,78 @@ const STAT_SFTP_TIMEOUT_MS = 10_000;
export const PREFLIGHT_STAT_CONCURRENCY = 8;
export const PREFLIGHT_STAT_MAX_FILES = 128;
// A "local" or missing resolved host means the dedicated pool should fall
// back to the saved vault host; only a concrete Host pins the endpoint.
const toConnectHost = (host: Host | "local" | null | undefined): Host | undefined =>
host && host !== "local" ? host : undefined;
// Stable identity of a pinned proxy transport. Two endpoints sharing
// id/hostname/port/username can still dial through different proxies after an
// edit; remote DNS behind each proxy may resolve the same hostname to a
// different server, so a same-host `cp` pinned to the source route could write
// on the wrong one.
const proxyIdentity = (p: Host["proxyConfig"]): string =>
p
? JSON.stringify([p.type, p.host, p.port, p.command ?? "", p.identityId ?? "", p.username ?? "", p.password ?? ""])
: "";
// Stable identity of a pinned jump chain. buildCacheKey omits the chain, so
// two panes on the same saved host can share a cache key while pinning
// different bastion routes (e.g. one connected before a jump-host edit, one
// after) — those routes may reach different servers. Compare identities, not
// just cache keys, before allowing any same-host optimization. proxyConfig
// must be part of the identity: buildSftpHostCredentials forwards it as the
// hop's proxy transport, so two hops sharing id/hostname/port/username can
// still dial through different proxies after an edit.
const chainIdentity = (chain: Host[] | undefined): string =>
(chain ?? []).map(h => `${h.id}@${h.hostname}:${h.port ?? ""}:${h.username}:${proxyIdentity(h.proxyConfig)}`).join("→");
// Only pin a concrete endpoint while the pane's live connection still belongs
// to it. connect() records the incoming tab host before it awaits closure of
// the old session and replaces the pane's connection, so during that window a
// transfer must not capture endpoint B while its pane snapshot, task paths,
// and plan still belong to endpoint A; the pool then falls back to the saved
// vault host for the pane's own hostId (mirrors resolveSftpReconnectHost's
// tab-host-vs-connection guard).
const toConnectHostForPane = (
pane: SftpPane,
host: Host | "local" | null | undefined,
): Host | undefined => {
const connection = pane.connection;
if (!connection || connection.isLocal) return undefined;
return host && host !== "local" && host.id === connection.hostId ? host : undefined;
};
// Snapshot the panes' connect-time endpoints. Called before any preflight
// await: tab ids survive reconnect, so resolving this map later can return a
// different endpoint than the one the pane snapshot, task paths, and transfer
// plan belong to.
const captureConnectHosts = (
resolveConnectedHost: UseSftpTransfersParams["resolveConnectedHost"],
resolveConnectedChain: UseSftpTransfersParams["resolveConnectedChain"],
...panes: SftpPane[]
): TransferConnectHosts => {
const captureEndpoint = (
pane: SftpPane | undefined,
): { host?: Host; chain?: Host[] } => {
if (!pane) return {};
const host = toConnectHostForPane(pane, resolveConnectedHost?.(pane.id));
if (!host) return {};
// Pin only the chain captured with this tab's connection; a later edit of
// its bastion hosts must not redirect retained transfers (private
// hostnames can resolve differently behind each bastion).
return { host, chain: resolveConnectedChain?.(pane.id) ?? undefined };
};
const source = captureEndpoint(panes[0]);
const target = captureEndpoint(panes[1]);
return {
source: source.host,
sourceChain: source.chain,
target: target.host,
targetChain: target.chain,
};
};
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
if (latched) ref.current.add(taskId);
@@ -187,6 +263,8 @@ export const useSftpTransfers = ({
listRemoteFiles,
handleSessionError,
acquireTransferSession,
resolveConnectedHost,
resolveConnectedChain,
}: UseSftpTransfersParams): UseSftpTransfersResult => {
const { t } = useI18n();
const tRef = useRef(t);
@@ -476,6 +554,8 @@ export const useSftpTransfers = ({
sourcePane: SftpPane,
targetPane: SftpPane,
targetSide: "left" | "right",
/** Connect-time endpoint snapshot captured before any preflight await. */
connectHostsOverride?: TransferConnectHosts,
): Promise<TransferStatus> => {
if (cancelledTasksRef.current.has(task.id)) {
return "cancelled";
@@ -534,7 +614,7 @@ export const useSftpTransfers = ({
let walkStatus: TransferStatus = "transferring";
await transferRuntime.runWalk(task.id, async () => {
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask);
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask, connectHostsOverride);
});
return walkStatus;
});
@@ -546,6 +626,8 @@ export const useSftpTransfers = ({
targetPane: SftpPane,
targetSide: "left" | "right",
updateTask: (updates: Partial<TransferTask>) => void,
/** Connect-time endpoint snapshot. Undefined re-resolves the live map. */
connectHostsOverride?: TransferConnectHosts,
): Promise<TransferStatus> => {
// Initialize encoding early to avoid temporal dead zone issues
@@ -597,13 +679,29 @@ export const useSftpTransfers = ({
&& sourceCacheKey === targetCacheKey
);
// Keep the pane's connect-time endpoint even when its saved host is edited.
// startTransfer snapshots both hosts before its preflight await so a
// reconnect (same tab id, same host id, different endpoint) pending when
// processing starts cannot redirect the transfer away from the endpoint
// its panes, paths, and plan were captured against.
const resolvedConnectHosts = connectHostsOverride
?? captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
const connectHosts: TransferConnectHosts = {
source: toConnectHost(resolvedConnectHosts.source),
sourceChain: resolvedConnectHosts.sourceChain,
target: toConnectHost(resolvedConnectHosts.target),
targetChain: resolvedConnectHosts.targetChain,
};
try {
// Prefer dedicated work leases for remote ends so folder walks/listings
// survive browse park and tab hide (pool holders, not panel map ids).
if (!sourcePane.connection?.isLocal) {
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
if (acquireTransferSession && sourceHostId) {
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
sourceWorkLease = await acquireTransferSession(
sourceHostId, `${task.id}:work-source`, connectHosts.source, connectHosts.sourceChain,
);
sourceSftpId = sourceWorkLease.sftpId;
}
if (!sourceSftpId) {
@@ -616,7 +714,9 @@ export const useSftpTransfers = ({
if (!targetPane.connection?.isLocal) {
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
if (acquireTransferSession && targetHostId) {
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
targetWorkLease = await acquireTransferSession(
targetHostId, `${task.id}:work-target`, connectHosts.target, connectHosts.targetChain,
);
targetSftpId = targetWorkLease.sftpId;
}
if (!targetSftpId) {
@@ -625,7 +725,20 @@ export const useSftpTransfers = ({
}
}
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
// The cache-key equality above omits the jump chain. When the two pinned
// chains differ (a bastion edit between the panes' connect times), the
// "same" saved host may resolve to different servers behind each bastion;
// a same-host `cp` would then write on the source-side server. Treat the
// transfer as cross-host unless the pinned routes match (both undefined =
// both direct, which is equal).
const sameHostPinnedChains = chainIdentity(connectHosts.sourceChain) === chainIdentity(connectHosts.targetChain);
// buildCacheKey also omits the endpoint's own top-level proxyConfig, and a
// proxy edit between the panes' connect times changes the transport (and
// possibly the resolved server) without moving the cache key. Compare the
// pinned top-level proxy identities too; undefined on both sides means
// both dial directly, which is equal.
const sameHostPinnedProxies = proxyIdentity(connectHosts.source?.proxyConfig) === proxyIdentity(connectHosts.target?.proxyConfig);
const sameHost = sameHostEndpoints && sameHostPinnedChains && sameHostPinnedProxies && !!sourceSftpId && !!targetSftpId;
// Returns the discovered stat so callers that need the size (e.g. the
// overwrite conflict dialog) can await this instead of reading stale
@@ -896,7 +1009,7 @@ export const useSftpTransfers = ({
: t,
),
);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
return processTransfer(updatedTask, sourcePane, targetPane, targetSide, connectHostsOverride);
}
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
@@ -978,6 +1091,7 @@ export const useSftpTransfers = ({
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
0, false, undefined, undefined, connectHosts,
);
if (dirErrors > 0) {
@@ -1025,6 +1139,7 @@ export const useSftpTransfers = ({
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
connectHosts,
);
}
@@ -1166,6 +1281,12 @@ export const useSftpTransfers = ({
if (!sourcePane?.connection || !targetPane?.connection) return [];
// Capture the connect-time endpoints before the preflight stat await
// below: reconnecting either pane to a different endpoint under the same
// host id while stats are pending must not redirect the transfer — the
// pane snapshot, task paths, and plan all belong to this endpoint.
const connectHosts = captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
@@ -1393,7 +1514,7 @@ export const useSftpTransfers = ({
}
const results = await Promise.all(runnableTasks.map(async (task): Promise<TransferResult> => {
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
const status = await processTransfer(task, sourcePane, targetPane, targetSide, connectHosts);
return {
id: task.id,
fileName: task.fileName,
@@ -1404,8 +1525,10 @@ export const useSftpTransfers = ({
return [...skippedResults, ...results];
},
// Saved host edits replace acquisition; new transfers must not retain the old endpoint.
// Already-running attempts keep the leases they acquired.
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab],
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab, acquireTransferSession, resolveConnectedHost],
);
const cancelTransfer = useCallback(
@@ -1,5 +1,5 @@
import type { MutableRefObject } from "react";
import type { FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import type { Host, FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import type { SftpPane } from "./types";
import type { AcquireTransferSessionFn } from "./transferDirectoryOps";
@@ -21,6 +21,14 @@ export interface UseSftpTransfersParams {
handleSessionError: (side: "left" | "right", error: Error) => void;
/** FileZilla-style dedicated transfer sessions (1–2 per host). */
acquireTransferSession?: AcquireTransferSessionFn;
resolveConnectedHost?: (tabId: string) => Host | "local" | null;
/**
* Jump-host chain snapshots captured when the tab connected. Lets retained
* transfers keep dialing the connect-time bastion chain after a chain host
* is edited (private hostnames can resolve differently per bastion);
* undefined = chain must be re-resolved from the live vault.
*/
resolveConnectedChain?: (tabId: string) => Host[] | null;
}
export interface UseSftpTransfersResult {
+2 -3
View File
@@ -266,9 +266,9 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
@@ -294,7 +294,6 @@ export function useSystemSettingsEffects({
if (!decision.shouldBroadcast) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
}, [
enabled,
windowOpacityRecord,
windowOpacityMutationSourceRef,
notifySettingsChanged,
+11
View File
@@ -18,6 +18,7 @@ import type {
} from '../../infrastructure/ai/types';
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { stopAgentTurn } from '../../infrastructure/ai/harness/agentStop';
import type {
TurnSteerInput,
TurnSteerResult,
@@ -46,6 +47,16 @@ export function isAIChatSessionStreaming(sessionId: string | null | undefined):
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
}
/** Use the same stop entry and controllers as the UI before replacing history. */
export async function stopAIChatSessionBeforeReplacement(sessionId: string): Promise<void> {
const controller = sharedAbortControllers.get(sessionId);
if (controller || sharedStreamingSessionIds.has(sessionId)) {
await stopAgentTurn({ chatSessionId: sessionId, abortController: controller, bridge: getNetcattyBridge() });
}
await getAgentRuntime().waitForActiveTurn(sessionId);
if (controller && sharedAbortControllers.get(sessionId) === controller) sharedAbortControllers.delete(sessionId);
}
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
+9 -1
View File
@@ -757,7 +757,10 @@ export function useAIState() {
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
const undoLastTurnInSession = useCallback(async (
sessionId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
@@ -768,6 +771,11 @@ export function useAIState() {
if (!result) return null;
const branched = result.session;
// Like Fork, Undo belongs to the panel viewing the source, which may have
// resumed a chat from a closed terminal or inherited it after a merge.
if (viewingScope) {
branched.scope = { ...branched.scope, ...viewingScope };
}
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
@@ -8,6 +8,7 @@ import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
@@ -192,3 +193,39 @@ for (const newerCount of [49, 50]) {
reloaded.container.remove(); reloaded.dom.window.close();
});
}
for (const viewingScope of [
{ type: 'terminal' as const, targetId: 'terminal-b' },
{ type: 'workspace' as const, targetId: 'workspace-b' },
]) {
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
const source = {
...structuredClone(SOURCE_SESSION),
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
};
const { dom, container, root, capture } = await setupAiState([source]);
const sourceBefore = structuredClone(source);
let branchId: string | undefined;
await act(async () => {
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
});
assert.ok(branchId);
const branch = capture.sessions.find(item => item.id === branchId)!;
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
// The real history predicate must still include the selected branch after
// the source terminal no longer belongs to the current workspace.
assert.equal(getSessionScopeMatchRank(
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
), 3);
const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
}
+16 -3
View File
@@ -21,12 +21,14 @@ import { isProviderReadyForSync, SYNC_STORAGE_KEYS, type CloudProvider, type Syn
import { mergeSyncPayloads } from '../../domain/syncMerge';
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
import {
isPreserveDeletionsStrategy,
resolveCloudSyncConflictAction,
type CloudSyncConflictAction,
} from '../../domain/syncStrategy';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectCloudSyncableSettings,
collectAiSessionsForSync,
getEffectivePortForwardingRulesForSync,
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
@@ -38,6 +40,8 @@ import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBri
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
@@ -159,6 +163,7 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
groupConfigs: payload.groupConfigs,
settings: payload.settings,
pluginSidecars: payload.pluginSidecars,
aiSessions: payload.aiSessions,
});
};
@@ -243,7 +248,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
if (!key) return;
const chatChanged = key === STORAGE_KEY_AI_SESSIONS
&& localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
if (!SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && !chatChanged) return;
setSyncableSettingsStorageVersion((v) => v + 1);
};
@@ -299,8 +308,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
const { withPluginSyncSidecars } = await import('../syncPayload');
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
const aiSessions = collectAiSessionsForSync();
const base: SyncPayload = {
...getSyncSnapshot(),
...(aiSessions !== undefined ? { aiSessions } : {}),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
@@ -781,7 +792,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const conflictAction = resolveCloudSyncConflictAction(state.syncStrategy, {
hasConflict: inspection.remoteChanged,
hasConflict: inspection.remoteChanged || isPreserveDeletionsStrategy(state.syncStrategy),
hasRemoteFile: Boolean(inspection.remoteFile),
});
@@ -850,7 +861,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// upload and wipe usable cloud passwords. Same for remote-side poison.
const localHealed = healPoisonedSecretsForMerge(localPayload, remoteRaw, base);
const remoteHealed = healPoisonedSecretsForMerge(remoteRaw, localPayload, base);
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed);
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed, {
preserveDeletions: isPreserveDeletionsStrategy(state.syncStrategy),
});
// Apply merged payload to local state BEFORE committing. If the apply
// throws, the next startup will re-run the merge with fresh data.
+40
View File
@@ -28,6 +28,8 @@ import {
isProviderReadyForSync,
} from '../../domain/sync';
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
import {
getCloudSyncManager,
@@ -173,6 +175,9 @@ export interface CloudSyncHook {
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
setDeviceName: (name: string) => void;
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
/** Whether AI chat sessions are included in cloud sync payloads (opt-in). */
aiSessionsSyncEnabled: boolean;
setAiSessionsSyncEnabled: (enabled: boolean) => Promise<void>;
setConvergentSyncEnabled: (enabled: boolean) => ConvergentSyncLocalConfig;
refreshConvergentSyncConfig: () => ConvergentSyncLocalConfig;
@@ -249,6 +254,25 @@ const getSnapshot = (): SyncManagerState => {
return manager.getState();
};
function readAiSessionsSyncEnabled(): boolean {
return localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
}
function subscribeAiSessionsSyncEnabled(listener: () => void): () => void {
const storage = (event: StorageEvent) => {
if (event.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS || event.key === null) listener();
};
const local = (event: Event) => {
if ((event as CustomEvent<{ key?: string }>).detail?.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS) listener();
};
window.addEventListener('storage', storage);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
return () => {
window.removeEventListener('storage', storage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
};
}
export const useCloudSync = (): CloudSyncHook => {
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
@@ -724,6 +748,19 @@ export const useCloudSync = (): CloudSyncHook => {
manager.setDeviceName(name);
}, []);
const aiSessionsSyncEnabled = useSyncExternalStore(
subscribeAiSessionsSyncEnabled, readAiSessionsSyncEnabled, readAiSessionsSyncEnabled,
);
const setAiSessionsSyncEnabled = useCallback(async (enabled: boolean) => {
const { setAiSessionsSyncPreference } = await import('../syncPayload');
await setAiSessionsSyncPreference(enabled, async () => {
const providers = Object.entries(manager.getAllProviders())
.filter(([, connection]) => isProviderReadyForSync(connection));
const remotes = await Promise.all(providers.map(([provider]) => manager.downloadFromProvider(provider as CloudProvider)));
return remotes.filter((remote) => remote !== null).map((remote) => remote.payload);
}, manager.getState().syncStrategy === 'smartMergePreserve');
}, []);
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
manager.setSyncStrategy(strategy);
}, []);
@@ -864,6 +901,7 @@ export const useCloudSync = (): CloudSyncHook => {
}
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
pluginSidecars,
aiSessions: (await import('../syncPayload')).collectAiSessionsForSync(),
});
}, [ensureUnlocked]);
@@ -956,6 +994,8 @@ export const useCloudSync = (): CloudSyncHook => {
setAutoSync,
setDeviceName,
setSyncStrategy,
aiSessionsSyncEnabled,
setAiSessionsSyncEnabled,
setConvergentSyncEnabled,
refreshConvergentSyncConfig,
+33 -1
View File
@@ -3,7 +3,11 @@ import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useComposeBarHistory } from './useComposeBarHistory';
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
import {
clearComposeBarHistory,
pruneComposeBarHistory,
recordComposeBarHistory,
} from './composeBarHistoryStore';
test('changing workspace focus resets navigation without replacing the draft', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -47,3 +51,31 @@ test('an in-progress history walk stays stable when a late send evicts the oldes
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('clearing history restores the pre-walk draft instead of leaving the recalled command', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'deleted-command');
const restoredDrafts: string[] = [];
let history!: ReturnType<typeof useComposeBarHistory>;
function Harness() {
history = useComposeBarHistory('a', (draft) => { restoredDrafts.push(draft); });
return null;
}
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
// Enter a history walk: the textarea now shows the recalled command while the
// hook holds the pre-walk draft.
assert.equal(history.navigate('my unsent draft', 'up'), 'deleted-command');
clearComposeBarHistory();
// The pre-walk draft is put back, so the deleted command is no longer in the
// textarea ready for Enter.
assert.deepEqual(restoredDrafts, ['my unsent draft']);
assert.equal(history.navigate('my unsent draft', 'up'), undefined);
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
+24 -2
View File
@@ -1,6 +1,10 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
import {
getComposeBarHistory,
createComposeBarHistoryRecorder,
subscribeComposeBarHistoryCleared,
} from './composeBarHistoryStore';
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
@@ -16,6 +20,24 @@ export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: s
reset();
}, [reset, restoreDraft, sessionId]);
/**
* Restore the pre-walk draft and drop the cached history walk.
*
* While the user is in an ArrowUp walk the textarea shows the recalled
* command, so resetting the cursor alone would leave a deleted command ready
* for Enter. Put the saved draft back first, exactly like the session-change
* path above.
*/
const handleHistoryCleared = useCallback(() => {
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
reset();
}, [reset, restoreDraft]);
useEffect(
() => subscribeComposeBarHistoryCleared(handleHistoryCleared),
[handleHistoryCleared],
);
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
+1
View File
@@ -39,6 +39,7 @@ export const getAppLevelActions = (): Set<string> => {
'togglePaneZoom',
'openLocal',
'openSettings',
'toggleOpacity',
]);
};
+41 -5
View File
@@ -141,7 +141,6 @@ import {
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
clampWindowOpacity,
applyThemeTokens,
areTerminalSettingsEqual,
createCustomKeyBindingsSyncOrigin,
@@ -180,6 +179,8 @@ import {
import {
parseWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
updateWindowOpacityRecord,
toggleWindowOpacityRecord,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
@@ -608,9 +609,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(prev.opacity)
: nextValue;
const nextOpacity = clampWindowOpacity(candidate);
if (nextOpacity === prev.opacity) return prev;
return { opacity: nextOpacity, version: prev.version + 1 };
return updateWindowOpacityRecord(prev, candidate);
});
}, []);
const applyIncomingWindowOpacity = useCallback((raw: unknown) => {
@@ -625,6 +624,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
return incoming;
});
}, []);
const toggleWindowOpacity = useCallback(() => {
windowOpacityMutationSourceRef.current = 'local';
setWindowOpacityRecord(toggleWindowOpacityRecord);
}, []);
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
@@ -785,7 +788,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
if (!enableSettingsSync) return;
if (!enableSettingsSync && key !== STORAGE_KEY_WINDOW_OPACITY) return;
try {
netcattyBridge.get()?.notifySettingsChanged?.({ key, value });
} catch {
@@ -1241,6 +1244,38 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshTransportIdleTtlMsState,
});
// Detached sessions share opacity and the shortcut configuration needed to
// operate it. Keep general settings listeners and OS effects disabled there.
useEffect(() => {
if (enableSettingsSync) return;
const applySharedWindowSetting = (key: string, value: unknown) => {
if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value);
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
const incoming = parseCustomKeyBindingsStorageRecord(value);
if (incoming) applyIncomingCustomKeyBindings(incoming);
}
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
setIsHotkeyRecordingState(value);
}
};
const unsubscribe = netcattyBridge.get()?.onSettingsChanged?.(({ key, value }) => {
applySharedWindowSetting(key, value);
});
const onStorage = (event: StorageEvent) => {
if (event.key !== null && event.newValue !== null) {
applySharedWindowSetting(event.key, event.newValue);
}
};
window.addEventListener('storage', onStorage);
return () => {
unsubscribe?.();
window.removeEventListener('storage', onStorage);
};
}, [enableSettingsSync, applyIncomingWindowOpacity, applyIncomingCustomKeyBindings]);
useEffect(() => {
if (!enableSettingsSync) return;
const bridge = netcattyBridge.get();
@@ -2183,6 +2218,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setGlobalHotkeyEnabled,
windowOpacity,
setWindowOpacity,
toggleWindowOpacity,
appIconVariant,
setAppIconVariant,
rehydrateAllFromStorage,
+21 -6
View File
@@ -191,6 +191,10 @@ export const useSftpState = (
// Full Host used when each tab connected (includes session-time overrides).
// Tab ids are stable across reconnect; connection ids are not.
const connectedHostByTabIdRef = useRef<Map<string, Host | "local">>(new Map());
// Resolved jump-host chain captured at connect time, keyed by tab id.
// Retained transfers dial through this pinned chain even when one of the
// chain's bastion hosts is edited later.
const connectedHostChainByTabIdRef = useRef<Map<string, Host[]>>(new Map());
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const connectRequestByTabIdRef = useRef<Map<string, symbol>>(new Map());
@@ -227,6 +231,7 @@ export const useSftpState = (
connectedHostByTabIdRef.current.set(resolvedTabId, host);
} else {
connectedHostByTabIdRef.current.delete(resolvedTabId);
connectedHostChainByTabIdRef.current.delete(resolvedTabId);
}
}, [leftTabsRef, rightTabsRef]);
@@ -240,6 +245,7 @@ export const useSftpState = (
connectRequests: connectRequestByTabIdRef.current,
connectInFlight: connectInFlightRef.current,
connectedHosts: connectedHostByTabIdRef.current,
connectedHostChains: connectedHostChainByTabIdRef.current,
closeTab: (liveSide) => closeTab(liveSide, tabId),
releaseConnection: async () => {
if (!pane?.connection) return;
@@ -318,7 +324,7 @@ export const useSftpState = (
const resolveBrowseSourceSessionId = options?.resolveBrowseSourceSessionId;
const openPoolSftpSession = useCallback(
async (host: Host) => {
async (host: Host, jumpHosts?: Host[]) => {
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
// not repeated. Transport leases keep the shared conn alive after the
// terminal tab closes until the transfer SFTP lease is returned.
@@ -337,7 +343,7 @@ export const useSftpState = (
knownHosts: transferKnownHosts,
terminalSettings: transferTerminalSettings,
},
{ dedicated: false, sourceSessionId },
{ dedicated: false, sourceSessionId, jumpHosts },
);
} catch (err) {
logger.debug(
@@ -361,14 +367,14 @@ export const useSftpState = (
knownHosts: transferKnownHosts,
terminalSettings: transferTerminalSettings,
},
{ dedicated: false },
{ dedicated: false, jumpHosts },
);
},
[hosts, identities, keys, resolveTransferSourceSessionId, transferKnownHosts, transferTerminalSettings],
);
const acquireTransferSession = useCallback(
async (hostId: string, transferId: string, connectHost?: Host) => {
async (hostId: string, transferId: string, connectHost?: Host, jumpHosts?: Host[]) => {
// Prefer the connect-time Host (terminal session overrides) so pooled
// uploads open the same endpoint as the browse tab, not the vault entry.
const host = connectHost && connectHost.id === hostId
@@ -382,7 +388,11 @@ export const useSftpState = (
`Transfer connect host id mismatch: expected ${hostId}, got ${connectHost.id}`,
);
}
const poolKey = await transferPoolKeyCache.get(host, () => ({
// Chain-pinned acquisitions key the pool-key cache by the chain snapshot
// (stable array captured at connect) so they cannot collide with the
// vault-resolved entry for the same host object in the WeakMap.
const cacheKey = jumpHosts?.length ? jumpHosts : host;
const poolKey = await transferPoolKeyCache.get(cacheKey, () => ({
hostId: host.id,
hostname: host.hostname,
port: host.port,
@@ -396,9 +406,10 @@ export const useSftpState = (
identities,
knownHosts: transferKnownHosts,
terminalSettings: transferTerminalSettings,
jumpHostsOverride: jumpHosts,
}),
}));
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host));
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host, jumpHosts));
},
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
);
@@ -443,6 +454,7 @@ export const useSftpState = (
lastConnectedHostRef,
connectionCacheKeyMapRef,
connectedHostByTabIdRef,
connectedHostChainByTabIdRef,
connectInFlightRef,
connectRequestByTabIdRef,
makeCacheKey,
@@ -557,6 +569,8 @@ export const useSftpState = (
listRemoteFiles,
handleSessionError,
acquireTransferSession,
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
});
const {
@@ -621,6 +635,7 @@ export const useSftpState = (
});
},
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
acquireTransferSession,
clearDirCacheEntry,
useCompressedUpload: options?.useCompressedUpload,
@@ -483,3 +483,73 @@ for (const phase of ["snapshot", "commit"] as const) {
assert.equal(commits, phase === "snapshot" ? 0 : 1);
});
}
for (const mixedSelection of [false, true]) {
test(`group deletion prunes host and inherited chains, mixed selection: ${mixedSelection}`, async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const storedHosts = [
{ id: "jump", group: "A", label: "jump", hostname: "j.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const },
{ id: "survivor", group: "B", label: "survivor", hostname: "s.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const, hostChain: { hostIds: ["jump"] } },
{ id: "extra", group: "B", label: "extra", hostname: "e.example.com", username: "root", tags: [], os: "linux" as const },
{ id: "kept", group: "B", label: "kept", hostname: "k.example.com", username: "root", tags: [], os: "linux" as const },
];
const configs: GroupConfig[] = [{ path: "B", hostChain: { hostIds: ["jump", "extra", "kept"] } }];
let deleteGroups: ReturnType<typeof useVaultGroupDeletion> | undefined;
let renderer: ReactTestRenderer | null = null;
let committedHosts: Array<{ id: string; hostChain?: { hostIds: string[] } }> = [];
let committedConfigs: GroupConfig[] = [];
const Probe = () => {
deleteGroups = useVaultGroupDeletion({
customGroups: ["A", "B"],
hosts: storedHosts,
groupConfigs: configs,
managedSources: [],
onReadPersistedHosts: async () => storedHosts,
onReadPersistedManagedSources: () => [],
onCommitVaultGroupMutation: async (mutate) => {
const result = mutate({
groups: ["A", "B"],
configs,
hosts: storedHosts,
managedSources: [],
snippets: [],
});
if (result.ok) {
committedHosts = result.state.hosts;
committedConfigs = result.state.configs;
}
return result;
},
});
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
await deleteGroups?.(["A"], true, new Set(mixedSelection ? ["extra"] : []));
});
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
assert.deepEqual(committedHosts.map(({ id, hostChain }) => ({ id, hostChain })), [
{ id: "survivor", hostChain: { hostIds: [] } },
...(!mixedSelection ? [{ id: "extra", hostChain: undefined }] : []),
{ id: "kept", hostChain: undefined },
]);
assert.deepEqual(committedConfigs[0].hostChain,
{ hostIds: mixedSelection ? ["kept"] : ["extra", "kept"] });
});
}
+21 -4
View File
@@ -3,6 +3,7 @@ import { useCallback, useRef } from "react";
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
import { pruneVaultJumpHostReferences } from "../../domain/vaultJumpGraph";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
@@ -155,14 +156,30 @@ export function useVaultGroupDeletion({
)) {
throw RETRY_VAULT_GROUP_DELETION;
}
const nextTransactionHosts = currentDeletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
);
// Hosts removed by this transaction must stop being referenced
// from other hosts' jump chains (and group config chains), or
// those targets keep failing with "A configured jump host is
// missing".
const retainedHostIds = new Set(
nextTransactionHosts.map((host) => host.id),
);
const removedHostIds = current.hosts
.filter((host) => !retainedHostIds.has(host.id))
.map((host) => host.id);
const prunedReferences = pruneVaultJumpHostReferences({
hosts: nextTransactionHosts,
groupConfigs: currentDeletion.groupConfigs,
removedHostIds,
});
return {
ok: true,
state: {
groups: currentDeletion.customGroups,
hosts: currentDeletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
),
configs: currentDeletion.groupConfigs,
hosts: prunedReferences.hosts,
configs: prunedReferences.groupConfigs,
managedSources: current.managedSources.filter(
(source) => !currentDeletion.sourcesToRemove.some(
(removed) => removed.id === source.id,
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host } from '../../domain/models';
import type { VaultGroupMutationState } from '../../domain/vaultGroupMutation';
import { useVaultHostDeletion } from './useVaultHostDeletion';
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
import {
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_HOSTS,
} from '../../infrastructure/config/storageKeys';
const host = (id: string, hostIds?: string[]): Host => ({
id, label: id, hostname: `${id}.test`, username: 'root', tags: [], os: 'linux',
...(hostIds ? { hostChain: { hostIds } } : {}),
});
async function withDeletion(
commit: Parameters<typeof useVaultHostDeletion>[0],
run: (remove: ReturnType<typeof useVaultHostDeletion>) => Promise<void>,
) {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let remove!: ReturnType<typeof useVaultHostDeletion>;
let renderer: ReactTestRenderer | undefined;
function Probe() {
remove = useVaultHostDeletion(commit);
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await run(remove);
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = previous;
}
}
for (const removed of [['jump'], ['jump', 'extra']]) {
test(`host deletion cleans latest host and group references together: ${removed.join(',')}`, async () => {
const latest: VaultGroupMutationState = {
hosts: [host('jump'), host('extra'), host('kept'), host('target', ['jump', 'kept'])],
configs: [{ path: 'prod', username: 'latest-user', hostChain: { hostIds: ['jump'] } }],
groups: ['prod'], managedSources: [], snippets: [],
};
let committed: VaultGroupMutationState | undefined;
await withDeletion(async (mutate) => {
const result = mutate(latest);
if (result.ok) committed = result.state;
return result;
}, async (remove) => { await act(async () => { await remove(removed); }); });
assert.ok(committed);
assert.deepEqual(committed.hosts.map((item) => item.id),
removed.length === 1 ? ['extra', 'kept', 'target'] : ['kept', 'target']);
assert.deepEqual(committed.hosts.at(-1)?.hostChain, { hostIds: ['kept'] });
assert.deepEqual(committed.configs[0], {
path: 'prod', username: 'latest-user', hostChain: { hostIds: [] },
});
assert.equal(committed.snippets, latest.snippets);
assert.deepEqual(latest.hosts.at(-1)?.hostChain, { hostIds: ['jump', 'kept'] });
});
}
test('a failed group-config write rolls host deletion back and reports failure', async () => {
const original = {
[STORAGE_KEY_HOSTS]: [host('jump'), host('target', ['jump'])],
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', hostChain: { hostIds: ['jump'] } }],
};
const values = new Map(Object.entries(original).map(([key, value]) => [key, JSON.stringify(value)]));
const storage = {
read: <T,>(key: string): T | null => JSON.parse(values.get(key) ?? 'null'),
readString: (key: string) => values.get(key) ?? null,
write: <T,>(key: string, value: T) => {
if (key === STORAGE_KEY_GROUP_CONFIGS) return false;
values.set(key, JSON.stringify(value));
return true;
},
writeString: (key: string, value: string) => { values.set(key, value); return true; },
remove: (key: string) => { values.delete(key); },
};
const identity = async <T,>(value: T) => value;
await withDeletion((mutate) => commitVaultGroupMutationPersistence({
storage, mutate, prepareState: (value) => value,
decryptHosts: identity, decryptConfigs: identity,
encryptHosts: identity, encryptConfigs: identity, isCurrent: () => true,
}), async (remove) => {
await assert.rejects(remove(['jump']), /rejected importer transaction/);
});
for (const [key, value] of Object.entries(original)) {
assert.deepEqual(storage.read(key), value);
}
});
test('host deletion reports a rejected or superseded transaction', async () => {
for (const failure of [{ ok: false, error: 'unavailable' }, { ok: false, superseded: true }] as const) {
await withDeletion(async () => failure, async (remove) => {
await assert.rejects(remove(['jump']), /unavailable|superseded/);
});
}
});
+29
View File
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import type { VaultGroupMutationResult, VaultGroupMutationState } from '../../domain/vaultGroupMutation';
import { pruneVaultJumpHostReferences } from '../../domain/vaultJumpGraph';
export function useVaultHostDeletion(
commit: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>,
) {
return useCallback(async (hostIds: Iterable<string>) => {
const removedHostIds = new Set(hostIds);
if (removedHostIds.size === 0) return;
const result = await commit((current) => {
const pruned = pruneVaultJumpHostReferences({
hosts: current.hosts.filter((host) => !removedHostIds.has(host.id)),
groupConfigs: current.configs,
removedHostIds,
});
return {
ok: true,
state: { ...current, hosts: pruned.hosts, configs: pruned.groupConfigs },
};
});
if (!result.ok) {
throw new Error('error' in result ? result.error : 'Host deletion was superseded. Try again.');
}
}, [commit]);
}
+22
View File
@@ -82,6 +82,10 @@ import {
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import {
clearHistory as clearAutocompleteHistory,
markCommandHistoryCleared,
} from "./commandHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
import {
@@ -1250,6 +1254,23 @@ export const useVaultState = () => {
publishShellHistorySnapshot([]);
}, []);
/**
* Clear every locally recorded command history: the global shell history, the
* terminal autocomplete history, and the in-memory compose-bar recall.
*
* The autocomplete store persists the clear synchronously and fans it out to
* other renderers through its own `storage` listener. The compose-bar store
* subscribes to that same clear event, so detached terminal popups drop their
* recall too without a separate broadcast.
*/
const clearAllCommandHistory = useCallback(() => {
clearShellHistory();
clearAutocompleteHistory();
// Tombstone so a later first-time histfile import cannot restore the
// commands the user just deleted.
markCommandHistoryCleared();
}, [clearShellHistory]);
const removeShellHistoryEntry = useCallback((entryId: string) => {
setShellHistory((prev) => {
const updated = removeGlobalHistoryEntry(prev, entryId);
@@ -2201,6 +2222,7 @@ export const useVaultState = () => {
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
clearAllCommandHistory,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
@@ -86,6 +86,7 @@ test('vault actions register and unregister', () => {
updateHostLastConnected: noop,
addShellHistoryEntry: noop,
removeShellHistoryEntry: noop,
clearAllCommandHistory: noop,
});
getVaultSnapshotActions()?.updateHosts([]);
assert.deepEqual(calls, ['hosts']);
+1
View File
@@ -63,6 +63,7 @@ export type VaultSnapshotActions = Pick<
| 'updateHostLastConnected'
| 'addShellHistoryEntry'
| 'removeShellHistoryEntry'
| 'clearAllCommandHistory'
>;
const EMPTY_HOSTS: readonly Host[] = Object.freeze([]);
@@ -6,6 +6,8 @@ import {
serializeWindowOpacityRecord,
shouldApplyWindowOpacityRecord,
shouldBroadcastWindowOpacityChange,
updateWindowOpacityRecord,
toggleWindowOpacityRecord,
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync.ts';
@@ -197,3 +199,23 @@ test('serializeWindowOpacityRecord round-trips through parse', () => {
const raw = serializeWindowOpacityRecord({ opacity: 0.55, version: 9 });
assert.deepEqual(parseWindowOpacityRecord(JSON.parse(raw)), { opacity: 0.55, version: 9 });
});
test('toggling off and restarting preserves a legacy selected opacity', () => {
const selected = parseWindowOpacityRecord('0.6');
const opaque = toggleWindowOpacityRecord(selected);
assert.equal(opaque.opacity, 1);
const restarted = parseWindowOpacityRecord(serializeWindowOpacityRecord(opaque));
assert.equal(toggleWindowOpacityRecord(restarted).opacity, 0.6);
assert.equal(toggleWindowOpacityRecord(restarted).version, selected.version + 2);
});
test('slider changes replace the preferred opacity and toggles use the latest record', () => {
const opaque = toggleWindowOpacityRecord({ opacity: 0.6, version: 2 });
const slider = updateWindowOpacityRecord(opaque, 0.7);
const next = toggleWindowOpacityRecord(toggleWindowOpacityRecord(slider));
assert.equal(next.opacity, 0.7);
assert.equal(next.preferredOpacity, 0.7);
assert.equal(next.version, 6);
assert.equal(updateWindowOpacityRecord(next, 0.7), next);
assert.equal(toggleWindowOpacityRecord({ opacity: 1, version: 0 }).opacity, 0.85);
});
+27 -1
View File
@@ -10,6 +10,7 @@ export type WindowOpacityMutationSource = 'local' | 'incoming';
export type WindowOpacityRecord = {
opacity: number;
version: number;
preferredOpacity?: number;
};
/**
@@ -34,12 +35,16 @@ export function parseWindowOpacityRecord(raw: unknown): WindowOpacityRecord {
}
if (raw && typeof raw === 'object') {
const record = raw as { opacity?: unknown; version?: unknown };
const record = raw as { opacity?: unknown; version?: unknown; preferredOpacity?: unknown };
const opacity = clampWindowOpacity(record.opacity);
const version = Number(record.version);
const preferred = Number(record.preferredOpacity);
return {
opacity,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
...(Number.isFinite(preferred) && preferred < 1
? { preferredOpacity: clampWindowOpacity(preferred) }
: {}),
};
}
@@ -50,9 +55,30 @@ export function serializeWindowOpacityRecord(record: WindowOpacityRecord): strin
return JSON.stringify({
opacity: clampWindowOpacity(record.opacity),
version: Math.max(0, Math.floor(record.version) || 0),
...(record.preferredOpacity !== undefined ? { preferredOpacity: record.preferredOpacity } : {}),
});
}
/** Slider changes and temporary toggles share the existing versioned setting. */
export function updateWindowOpacityRecord(
current: WindowOpacityRecord,
nextValue: number,
): WindowOpacityRecord {
const opacity = clampWindowOpacity(nextValue);
if (opacity === current.opacity) return current;
const preferredOpacity = opacity < 1 ? opacity
: current.opacity < 1 ? current.opacity : current.preferredOpacity;
return {
opacity,
version: current.version + 1,
...(preferredOpacity !== undefined ? { preferredOpacity } : {}),
};
}
export function toggleWindowOpacityRecord(current: WindowOpacityRecord): WindowOpacityRecord {
return updateWindowOpacityRecord(current, current.opacity < 1 ? 1 : (current.preferredOpacity ?? 0.85));
}
/**
* Incoming peer updates must not clobber a newer local/drag revision.
* Equal versions are treated as already-applied (no state thrash).
+337
View File
@@ -0,0 +1,337 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AISession } from '../infrastructure/ai/types';
import type { SyncPayload } from '../domain/sync';
import { withSyncReliabilityMeta } from '../domain/syncReliability';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
const storage = new Map<string, string>();
const events = new EventTarget();
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
} });
Object.defineProperty(globalThis, 'window', { configurable: true, value: events });
const keys = await import('../infrastructure/config/storageKeys');
const snapshots = await import('./state/aiStateSnapshots');
const { getAISessionsSnapshot } = await import('./state/aiSessionsStore');
const {
buildCloudSyncPayload, buildLocalVaultPayload, collectAiSessionsForSync,
applySyncPayload, applyLocalVaultPayload, hasMeaningfulSyncData,
setAiSessionsSyncPreference,
hasCloudSyncEntityData, hasMeaningfulCloudSyncData, shouldPromptCloudVaultRecovery,
} = await import('./syncPayload');
const vault = { hosts: [], keys: [], identities: [], snippets: [], customGroups: [], knownHosts: [] };
const remotePayload = (aiSessions: AISession[]): SyncPayload => ({ ...vault, aiSessions, syncedAt: 1 });
const session = (): AISession => ({
id: 'chat', title: 'Chat', agentId: 'catty', scope: { type: 'global' }, createdAt: 1, updatedAt: 2,
messages: [{ id: 'message', role: 'user', content: 'Inspect', timestamp: 1, attachments: [{
filename: 'terminal.log', mediaType: 'text/plain', terminalSelection: true,
base64Data: 'cHJpdmF0ZQ==', previewText: 'private', filePath: '/private/device/file', lineCount: 1,
}] }],
});
function save(sessions: AISession[], enabled = true) {
snapshots.setLatestAISessionsSnapshot(sessions);
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify(sessions));
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
}
test.beforeEach(() => { storage.clear(); save([], false); });
test('actual manual upload and auto collector honor consent and explicit empty', async () => {
save([session()], false);
const options = { collectPluginSidecars: () => undefined };
assert.equal((await buildCloudSyncPayload(vault, [], options)).aiSessions, undefined);
assert.equal(collectAiSessionsForSync(), undefined);
save([], true);
assert.deepEqual((await buildCloudSyncPayload(vault, [], options)).aiSessions, []);
assert.deepEqual(collectAiSessionsForSync(), []);
save([session()], true);
const cloud = await buildCloudSyncPayload(vault, [], options);
assert.equal(cloud.aiSessions?.length, 1);
assert.deepEqual(cloud.aiSessions, collectAiSessionsForSync());
assert.equal(JSON.stringify(cloud).includes('cHJpdmF0ZQ=='), false);
assert.equal(JSON.stringify(cloud).includes('/private/device/file'), false);
assert.equal(JSON.stringify(cloud.aiSessions?.[0].messages[0].attachments).includes('private'), false);
});
test('sync persistence survives restart beyond the local-only history limits', () => {
const many = Array.from({ length: 51 }, (_, index) => ({ ...session(), id: `chat-${index}`, updatedAt: index,
messages: Array.from({ length: 201 }, (_, turn) => ({ id: `m-${turn}`, role: 'user' as const, content: 'History', timestamp: turn })) }));
save(many);
assert.equal(snapshots.writeSessionsForStorage(many), true);
assert.equal(collectAiSessionsForSync()?.length, 51);
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
const afterRestart = collectAiSessionsForSync()!;
assert.equal(afterRestart.length, 51);
assert.equal(afterRestart[0].messages.length, 201);
save(many, false);
assert.equal(snapshots.writeSessionsForStorage(many), true);
const localOnly = JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!);
assert.equal(localOnly.length, 50);
assert.equal(localOnly[0].messages.length, 200);
});
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
save([session()]);
const original = globalThis.localStorage.setItem;
globalThis.localStorage.setItem = (key, value) => {
if (key === keys.STORAGE_KEY_AI_SESSIONS) throw new DOMException('Full', 'QuotaExceededError');
original(key, value);
};
snapshots.setLatestAISessionsSnapshot([{ ...session(), title: 'Unsaved change' }]);
try {
await assert.rejects(buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined }), /persist|save/i);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].title, 'Chat');
const backup = buildLocalVaultPayload(vault);
assert.equal(backup.aiSessions?.[0].title, 'Unsaved change');
assert.equal(backup.aiSessions?.[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
} finally { globalThis.localStorage.setItem = original; }
});
test('failed opt-in preference commit leaves local chats and native threads untouched', async () => {
const local = { ...session(), externalSessionId: 'native-thread' };
save([local], false);
const original = globalThis.localStorage.setItem;
const cleanups: string[] = [];
Object.assign(window, { netcatty: { aiSdkAgentCleanup: async (id: string) => { cleanups.push(id); return { ok: true }; } } });
globalThis.localStorage.setItem = (key, value) => {
if (key === keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS) throw new DOMException('Full', 'QuotaExceededError');
original(key, value);
};
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
{ id: 'remote', role: 'assistant' as const, content: 'Remote turn', timestamp: 3 }] };
try {
await assert.rejects(setAiSessionsSyncPreference(true, async () => [remotePayload([incoming])]), /preference|enable/i);
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
assert.deepEqual(cleanups, []);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)!).enabled, false);
} finally { globalThis.localStorage.setItem = original; delete (window as unknown as { netcatty?: unknown }).netcatty; }
});
test('local backup retains chat and attachment bytes with cloud consent off', async () => {
const local = session();
save([local], false);
const backup = buildLocalVaultPayload(vault);
assert.deepEqual(backup.aiSessions, [local]);
assert.equal(hasMeaningfulSyncData({ ...vault, aiSessions: [local], syncedAt: 1 }), true);
save([], false);
await applyLocalVaultPayload(backup, { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
assert.equal(getAISessionsSnapshot().sessions[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
});
test('chat-only data is syncable but does not suppress empty-vault recovery', () => {
const local = { ...vault, aiSessions: [session()], syncedAt: 1 };
assert.equal(hasMeaningfulCloudSyncData(local), true);
assert.equal(hasCloudSyncEntityData(local), false);
const host = { id: 'remote', label: 'Remote', hostname: 'remote.example', username: 'test', port: 22, protocol: 'ssh' as const, tags: [], os: 'linux' as const };
for (const hosts of [[host], [host, { ...host, id: 'second' }]]) {
assert.equal(shouldPromptCloudVaultRecovery(local, { ...vault, hosts, syncedAt: 2 }), true);
}
assert.equal(shouldPromptCloudVaultRecovery({ ...vault, syncedAt: 1 }, local), false);
});
test('download publishes immediately and keeps local attachment bytes on a newer message', async () => {
save([session()]);
const incoming = session();
incoming.updatedAt = 3;
incoming.messages[0].content = 'Updated';
incoming.messages[0].attachments = [{ base64Data: '', mediaType: 'text/plain', filename: 'terminal.log', terminalSelection: true, contentUnavailable: true }];
const payload: SyncPayload = { ...vault, aiSessions: [incoming], syncedAt: 2 };
await applySyncPayload(payload, { importVaultData: () => undefined });
const current = getAISessionsSnapshot().sessions[0];
assert.equal(current.messages[0].content, 'Updated');
assert.equal(current.messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
assert.equal(snapshots.latestAISessionsSnapshot?.[0], current);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].messages[0].content, 'Updated');
});
test('restoring an old backup without chat leaves current chats alone', async () => {
const local = session();
save([local], false);
await applyLocalVaultPayload({ ...vault, syncedAt: 1 }, { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
});
test('authoritative cloud session removal stays removed in the next upload', async () => {
save([session()]);
await applySyncPayload({ ...vault, aiSessions: [], syncedAt: 3 }, { importVaultData: () => undefined });
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(collectAiSessionsForSync(), []);
assert.deepEqual((await buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined })).aiSessions, []);
});
test('replacement stops active turns and waits for finalizers before persisting history', async (context) => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let hook: UseAIChatStreamingReturn;
function Harness() {
hook = useAIChatStreaming({ maxIterations: 1, addMessageToSession: () => {}, updateLastMessage: () => {}, updateMessageById: () => {} });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
const original = globalThis.localStorage.setItem;
try {
for (const remove of [false, true]) {
const order: string[] = [];
const controller = new AbortController();
const local = { ...session(), messages: [...session().messages,
{ id: 'reply', role: 'assistant' as const, content: 'Partial', timestamp: 2, executionStatus: 'running' as const }] };
save([local]);
hook!.abortControllersRef.current.set('chat', controller);
await act(async () => { hook!.setStreamingForScope('chat', true); });
Object.assign(window, { netcatty: {
aiCattyCancelExec: async () => { order.push('cancel'); },
aiSdkAgentCancel: async () => ({ ok: true }),
aiSdkAgentCleanup: async () => { order.push('cleanup'); return { ok: true }; },
} });
const wait = context.mock.method(getAgentRuntime(), 'waitForActiveTurn', async () => {
assert.equal(controller.signal.aborted, true);
await new Promise(resolve => setTimeout(resolve, 0));
snapshots.setLatestAISessionsSnapshot([{ ...local, messages: [local.messages[0],
{ ...local.messages[1], content: 'Settled', executionStatus: 'cancelled' }] }]);
order.push('settled');
});
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
await applySyncPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined });
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
else assert.ok(getAISessionsSnapshot().sessions[0].messages.some(message => message.content === 'Settled'));
wait.mock.restore();
globalThis.localStorage.setItem = original;
await act(async () => { hook!.setStreamingForScope('chat', false); });
}
} finally {
context.mock.restoreAll(); globalThis.localStorage.setItem = original;
delete (window as unknown as { netcatty?: unknown }).netcatty;
await act(async () => { renderer!.unmount(); });
}
});
test('local restore replaces later sessions and later turns with the backup snapshot', async () => {
const backedUp = session();
const later = { ...session(), updatedAt: 3, messages: [...session().messages,
{ id: 'later', role: 'assistant' as const, content: 'After backup', timestamp: 3 }] };
save([later, { ...session(), id: 'created-after-backup' }]);
await applyLocalVaultPayload({ ...vault, aiSessions: [backedUp], syncedAt: 1 }, { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
assert.deepEqual(getAISessionsSnapshot().sessions, [backedUp]);
await applyLocalVaultPayload({ ...vault, aiSessions: [], syncedAt: 1 }, { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
assert.deepEqual(getAISessionsSnapshot().sessions, []);
});
test('cloud downloads respect local chat opt-in while still applying vault data', async () => {
save([], false);
let vaultImports = 0;
const downloaded = { ...vault, aiSessions: [session()], syncedAt: 2 };
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
assert.equal(vaultImports, 1);
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), []);
save([], true);
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
assert.equal(vaultImports, 2);
assert.equal(snapshots.latestAISessionsSnapshot?.[0].id, 'chat');
});
test('enabling sync imports existing cloud chats before an empty local list can be uploaded', async () => {
save([], false);
let loads = 0;
await setAiSessionsSyncPreference(true, async () => {
loads++;
assert.equal(collectAiSessionsForSync(), undefined);
return [remotePayload([session()])];
});
assert.equal(loads, 1);
assert.equal(collectAiSessionsForSync()?.[0].id, 'chat');
assert.equal(getAISessionsSnapshot().sessions[0].id, 'chat');
await setAiSessionsSyncPreference(false, async () => { throw new Error('Disable must not load'); });
assert.equal(collectAiSessionsForSync(), undefined);
});
test('an unsuccessful initial cloud read leaves chat sync disabled and local history unchanged', async () => {
save([], false);
await assert.rejects(setAiSessionsSyncPreference(true, async () => { throw new Error('offline'); }), /offline/);
assert.equal(collectAiSessionsForSync(), undefined);
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
});
test('enabling from multiple mirrored providers retains the newer turns from either copy', async () => {
save([], false);
const newer = session();
newer.updatedAt = 3;
newer.messages.push({ id: 'new-turn', role: 'assistant', content: 'Newer provider turn', timestamp: 3 });
await setAiSessionsSyncPreference(true, async () => [remotePayload([newer]), remotePayload([session()])]);
assert.deepEqual(collectAiSessionsForSync()?.[0].messages.map((message) => message.id), ['message', 'new-turn']);
});
test('initial enable retains local-only chats but respects remote deletions and preservation', async () => {
const localOnly = { ...session(), id: 'local-only' };
save([localOnly], false);
await setAiSessionsSyncPreference(true, async () => [remotePayload([session()])]);
assert.deepEqual(collectAiSessionsForSync()?.map(item => item.id).sort(), ['chat', 'local-only']);
const removed = withSyncReliabilityMeta(remotePayload([]), remotePayload([session()]), { now: 3 });
for (const preserve of [false, true]) {
save([session(), localOnly], false);
await setAiSessionsSyncPreference(true, async () => [removed, remotePayload([session()])], preserve);
assert.deepEqual(collectAiSessionsForSync()?.map(item => item.id).sort(), preserve ? ['chat', 'local-only'] : ['local-only']);
}
});
test('backup rollback resets the native thread that still contains later turns', async () => {
const cleanups: string[] = [];
Object.assign(window, { netcatty: { aiSdkAgentCleanup: async (id: string) => { cleanups.push(id); return { ok: true }; } } });
const backedUp = { ...session(), agentId: 'codex', externalSessionId: 'native-thread' };
save([{ ...backedUp, updatedAt: 4, messages: [...backedUp.messages,
{ id: 'later', role: 'assistant', content: 'After backup', timestamp: 4 }] }]);
await applyLocalVaultPayload(remotePayload([backedUp]), { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
const restored = getAISessionsSnapshot().sessions[0];
assert.equal(restored.externalSessionId, undefined);
assert.deepEqual(restored.messages, backedUp.messages);
assert.deepEqual(cleanups, ['chat']);
delete (window as unknown as { netcatty?: unknown }).netcatty;
});
test('cloud restore never resumes a source device SDK thread, and preserves matching local threads', async () => {
const local = { ...session(), externalSessionId: 'local-native-thread' };
save([local]);
const incoming = { ...session(), updatedAt: 3, externalSessionId: 'remote-native-thread' };
const fresh = { ...incoming, id: 'new-chat' };
await applySyncPayload({ ...vault, aiSessions: [incoming, fresh], syncedAt: 2 }, { importVaultData: () => undefined });
const current = getAISessionsSnapshot().sessions;
assert.equal(current.find((s) => s.id === 'chat')?.externalSessionId, 'local-native-thread');
assert.equal(current.find((s) => s.id === 'new-chat')?.externalSessionId, undefined);
});
test('changed remote transcript resets the local native SDK thread before continuing', async () => {
const cleanups: string[] = [];
Object.assign(window, { netcatty: { aiSdkAgentCleanup: async (id: string) => { cleanups.push(id); return { ok: true }; } } });
save([{ ...session(), agentId: 'codex', externalSessionId: 'local-native-thread' }]);
const incoming = { ...session(), agentId: 'codex', updatedAt: 3, externalSessionId: 'remote-native-thread' };
incoming.messages.push({ id: 'remote-turn', role: 'user', content: 'Added on another device', timestamp: 3 });
await applySyncPayload({ ...vault, aiSessions: [incoming], syncedAt: 2 }, { importVaultData: () => undefined });
assert.deepEqual(cleanups, ['chat']);
assert.equal(getAISessionsSnapshot().sessions[0].externalSessionId, undefined);
assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Added on another device');
delete (window as unknown as { netcatty?: unknown }).netcatty;
});
+149 -15
View File
@@ -46,6 +46,14 @@ import { localStorageAdapter } from '../infrastructure/persistence/localStorageA
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import {
latestAISessionsSnapshot,
setLatestAISessionsSnapshot,
writeSessionsForStorage,
cleanupSdkAgentSessions,
cleanupDeletedAIChatSessions,
} from './state/aiStateSnapshots';
import { getAISessionsSnapshot, publishAISessionsSnapshot } from './state/aiSessionsStore';
import {
persistCommandBlocklistSetting,
readCommandBlocklistSetting,
@@ -64,6 +72,7 @@ import {
import { isValidHslToken } from './state/settingsStateDefaults';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
@@ -125,7 +134,10 @@ import {
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../infrastructure/config/storageKeys';
import { mergeAiSessionLists, mergeSyncPayloads, stripAiSessionDeviceState, stripAiSessionPayloadForSync } from '../domain/syncMerge';
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
@@ -171,6 +183,7 @@ function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
if (Array.isArray(payload.aiSessions) && payload.aiSessions.length > 0) return true;
if (
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
@@ -200,11 +213,11 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
/**
* Returns true only when the payload contains synced vault entities.
* Settings are intentionally ignored so default settings written on first
* launch do not make a new device look non-empty during cloud restore checks.
* Settings and chat history are ignored: neither proves that the vault's
* hosts and keys survived local data loss.
*/
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS);
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions'));
}
export function shouldPromptCloudVaultRecovery(
@@ -1029,15 +1042,40 @@ function pruneOrphanPerAgentBindings(): void {
// ---------------------------------------------------------------------------
/**
* Build a complete `SyncPayload` from local data.
*
* Port-forwarding rules are optional because they are managed by a separate
* state hook (`usePortForwardingState`). Callers should strip transient
* runtime fields (status, error, lastUsedAt) before passing them in.
* AI chat sessions only leave the device when the user opted in.
* Attachments are stripped — see {@link stripAiSessionPayloadForSync}.
*/
export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
const enabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
if (enabled?.enabled !== true) return undefined;
const sessions = collectLocalAiSessions();
if (!writeSessionsForStorage(sessions)) {
throw new Error('Unable to persist chat history for cloud sync');
}
return stripAiSessionPayloadForSync(sessions);
}
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
return latestAISessionsSnapshot
?? localStorageAdapter.read<SyncPayload['aiSessions']>(STORAGE_KEY_AI_SESSIONS)
?? [];
}
/** Build the portable payload; device-local data is added by the backup builder. */
export function buildSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
const aiSessions = collectAiSessionsForSync();
return {
...buildVaultSyncPayload(vault, portForwardingRules),
...(aiSessions !== undefined ? { aiSessions } : {}),
};
}
function buildVaultSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
hosts: sanitizeHostsForSync(vault.hosts),
@@ -1124,6 +1162,7 @@ export async function buildCloudSyncPayload(
collectPluginSidecars?: PluginSyncSidecarCollector;
},
): Promise<SyncPayload> {
const aiSessions = collectAiSessionsForSync();
const base: SyncPayload = {
hosts: sanitizeHostsForSync(vault.hosts),
keys: vault.keys,
@@ -1136,6 +1175,7 @@ export async function buildCloudSyncPayload(
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
...(aiSessions !== undefined ? { aiSessions } : {}),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
@@ -1150,11 +1190,13 @@ export function buildLocalVaultPayload(
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
const base: SyncPayload = {
...buildSyncPayload(vault, portForwardingRules),
...buildVaultSyncPayload(vault, portForwardingRules),
// Keep device-local recent-connection timestamps in protective backups.
hosts: vault.hosts,
settings: collectLocalBackupSettings(),
knownHosts: vault.knownHosts,
// Cloud consent and cloud attachment stripping do not apply to local backups.
aiSessions: collectLocalAiSessions(),
};
// Protective backups must capture plugin sidecars so restore can recover
// plugin settings/baselines. Prefer the last successful host collect cache
@@ -1207,12 +1249,96 @@ export async function buildLocalVaultPayloadAsync(
return base;
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
/** Merge downloaded chat data through the existing state and persistence boundary. */
async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions']>, includeLocalOnlyData: boolean): Promise<void> {
const incoming = includeLocalOnlyData
? sessions
: sessions.map(stripAiSessionDeviceState);
const incomingIds = new Set(incoming.map((session) => session.id));
const incomingById = new Map(incoming.map((session) => [session.id, session]));
const portableMessages = (value: typeof incoming[number]) => stripAiSessionPayloadForSync([value])[0].messages;
const affectedIds = collectLocalAiSessions().filter((local) => {
const next = incomingById.get(local.id);
if (!next) return true;
const candidate = includeLocalOnlyData ? next : mergeAiSessionLists([local], [next])[0];
return JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(candidate));
}).map((session) => session.id);
if (affectedIds.length > 0) {
const { stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
await Promise.all(affectedIds.map(stopAIChatSessionBeforeReplacement));
}
// Stop finalizers can finish the last local message. Read it again before
// merging so the stopped turn cannot write into a replaced/deleted session.
const localSessions = collectLocalAiSessions();
const localById = new Map(localSessions.map((session) => [session.id, session]));
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id)).map((session) => session.id);
const resetSdkSessions: string[] = [];
// The sync merge has already decided which session IDs survive. Backups
// restore their exact transcript; cloud survivors still retain local turns.
const candidates = includeLocalOnlyData ? incoming
: mergeAiSessionLists(localSessions.filter((session) => incomingIds.has(session.id)), incoming);
const mergedSessions = candidates.map((session) => {
// Native SDK threads belong to this device. Reuse the local thread
// only while it already contains the entire merged transcript.
const local = localById.get(session.id);
const transcriptChanged = local
&& JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(session));
if (transcriptChanged) resetSdkSessions.push(session.id);
const { externalSessionId: _remoteThread, ...portable } = session;
return local?.externalSessionId && !transcriptChanged
? { ...portable, externalSessionId: local.externalSessionId }
: portable;
});
if (!writeSessionsForStorage(mergedSessions)) {
throw new Error('Unable to save downloaded AI chat history');
}
cleanupDeletedAIChatSessions(removedIds);
await cleanupSdkAgentSessions(resetSdkSessions);
setLatestAISessionsSnapshot(mergedSessions);
publishAISessionsSnapshot({ ...getAISessionsSnapshot(), sessions: mergedSessions });
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
/** Read existing cloud chats before an enabled empty local list can overwrite them. */
export async function setAiSessionsSyncPreference(
enabled: boolean,
loadRemotePayloads: () => Promise<SyncPayload[]>,
preserveDeletions = false,
): Promise<void> {
const wasEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
if (enabled && !wasEnabled) {
const remotes = await loadRemotePayloads();
// Reuse the ordinary no-base deletion rules, including cloud tombstones,
// while retaining local-only chats when this is a first-time opt-in.
const local: SyncPayload = { hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
aiSessions: collectLocalAiSessions(), syncedAt: 0 };
const merged = remotes.reduce((current, remote) =>
mergeSyncPayloads(null, current, remote, { preserveDeletions }).payload, local);
const previous = localStorageAdapter.readString(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
const { withRestoreBarrier } = await import('./localVaultBackups');
await withRestoreBarrier(async () => {
if ((localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL) ?? 0) <= Date.now()) {
throw new Error('Unable to pause sync while enabling chat history');
}
if (!localStorageAdapter.write(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, { enabled })) {
throw new Error('Unable to save the chat sync preference');
}
try {
await applyAiSessionData(merged.aiSessions ?? [], false);
} catch (error) {
if (previous === null) localStorageAdapter.remove(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
else localStorageAdapter.writeString(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, previous);
throw error;
}
});
return;
}
if (!localStorageAdapter.write(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, { enabled })) {
throw new Error('Unable to save the chat sync preference');
}
}
/** Apply downloaded vault data and supported settings through their state boundaries. */
async function preparePayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
@@ -1275,6 +1401,14 @@ async function preparePayloadApply(
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
}
// AI chat sessions merge append-only: a cloud snapshot may be missing
// turns that only exist on this device, so union instead of replace.
const applyAiSessions = options.includeLocalOnlyData
|| localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
if (applyAiSessions && Array.isArray(sanitizedPayload.aiSessions)) {
await applyAiSessionData(sanitizedPayload.aiSessions, options.includeLocalOnlyData);
}
// Apply synced settings
if (sanitizedPayload.settings) {
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
+8
View File
@@ -73,6 +73,10 @@ interface AIChatPanelContentProps {
modelCatalogWarning?: string;
/** Live SDK model catalog is loading (no built-in presets to show yet). */
modelCatalogLoading?: boolean;
/** Provenance of the active model list, rendered as the picker status chip. */
modelCatalogSource?: 'live' | 'last-known-good' | 'presets';
/** ISO timestamp of the successful fetch backing `modelCatalogSource`. */
modelCatalogFetchedAt?: string;
/** Retry a failed SDK model catalog load from the warning banner. */
handleModelCatalogRetry?: () => void;
agentModelPresets: AgentModelPreset[];
@@ -153,6 +157,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
modelDisplayName,
modelCatalogWarning,
modelCatalogLoading = false,
modelCatalogSource,
modelCatalogFetchedAt,
agentModelPresets,
allowCustomModelEntry = true,
selectedAgentModel,
@@ -403,6 +409,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
modelCatalogLoading={modelCatalogLoading}
modelCatalogSource={modelCatalogSource}
modelCatalogFetchedAt={modelCatalogFetchedAt}
allowCustomModelEntry={allowCustomModelEntry}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
@@ -165,6 +165,18 @@ test('hidden empty AI side panel can release its subtree', () => {
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
});
test('Catty without an SDK catalog request does not claim models are loading', () => {
const html = renderToStaticMarkup(
React.createElement(I18nProvider, {
locale: 'en',
children: React.createElement(TooltipProvider, {
children: React.createElement(AIChatSidePanel, baseProps({ isVisible: true })),
}),
}),
);
assert.doesNotMatch(html, /Loading models|ai\.chat\.loadingModels/);
});
test('hidden AI side panel is retained when it has draft text', () => {
const props = baseProps({
draftsByScope: {
+151 -68
View File
@@ -80,15 +80,21 @@ import {
buildCursorListModelsAgentEnv,
buildSdkRuntimeModelCacheKey,
sdkRuntimeModelCache,
reconcileRuntimeModelWarning,
retainStoredModelPresetForCatalog,
toSdkRuntimeModelCatalog,
generateId,
agentModelPresetsShallowEqual,
mergeFallbackThinkingLevels,
normalizeStoredAgentModelSelection,
normalizeSdkRuntimeModelPresets,
resolveSdkRuntimeCatalogDefaultModelId,
shouldAdoptSdkCurrentModel,
appendRetainedStoredModelPreset,
shouldLoadSdkRuntimeModels,
shouldUseStoredAgentModel,
type SdkRuntimeModelCatalog,
type SdkRuntimeModelCatalogSource,
} from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
@@ -327,12 +333,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
// is tracked separately to block sends until the branch settles: a turn
// submitted meanwhile would run against the (soon hidden) source session.
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[]; catalogUnavailable: boolean }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
// Tracks SDK runtime model catalogs currently loading (per agent scope):
// while an OpenCode catalog is pending we must not flash the built-in
// OPENCODE_MODEL_PRESETS for providers the user never configured (#3584).
// Tracks SDK runtime model catalogs currently loading (per agent scope);
// the composer shows the loading row alongside the build-time presets and
// the live catalog replaces them when it arrives.
const [runtimeModelLoading, setRuntimeModelLoading] = useState<Record<string, { cacheKey: string }>>({});
// Provenance of the last applied SDK runtime catalog, per agent scope. The
// picker renders a uniform status chip from it; when no runtime catalog is
// active the composer falls back to build-time presets ('presets').
const [runtimeModelCatalogMeta, setRuntimeModelCatalogMeta] = useState<Record<string, {
cacheKey: string;
source: SdkRuntimeModelCatalogSource;
fetchedAt?: string;
}>>({});
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
@@ -852,7 +866,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId);
const result = await undoLastTurnInSession(sessionId, {
type: scopeType,
targetId: scopeTargetId,
});
if (!result) return;
ensureScopeDraft(currentAgentId);
@@ -899,6 +916,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
scopeType,
scopeTargetId,
]);
const providersRef = useRef(providers);
@@ -994,29 +1013,37 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return;
const agentId = target.agentId;
// Record the catalog provenance for the picker's status chip. An absent
// source (older main without the marker) displays as a live catalog.
setRuntimeModelCatalogMeta((prev) => ({
...prev,
[agentId]: {
cacheKey: target.cacheKey,
source: catalog.source ?? 'live',
...(catalog.fetchedAt ? { fetchedAt: catalog.fetchedAt } : {}),
},
}));
const catalogPresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
);
const runtimePresets = appendComposerCustomModelPresets(
const runtimePresets = retainStoredModelPresetForCatalog(
catalogPresets,
collectCustomModelIds(agentId, catalogPresets),
appendComposerCustomModelPresets(
catalogPresets,
collectCustomModelIds(agentId, catalogPresets),
),
agentModelMapRef.current[agentId],
agent,
Boolean(catalog.warning),
);
const storedModelId = agentModelMapRef.current[agentId];
if (runtimePresets.length === 0) {
setRuntimeAgentModelPresets((prev) => {
if (!(agentId in prev)) return prev;
const { [agentId]: _removed, ...rest } = prev;
return rest;
});
} else {
setRuntimeAgentModelPresets((prev) => (
prev[agentId]?.cacheKey === target.cacheKey
&& agentModelPresetsShallowEqual(prev[agentId].models, runtimePresets)
? prev
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
));
}
setRuntimeAgentModelPresets((prev) => (
prev[agentId]?.cacheKey === target.cacheKey
&& prev[agentId].catalogUnavailable === Boolean(catalog.warning)
&& agentModelPresetsShallowEqual(prev[agentId].models, catalogPresets)
? prev
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: catalogPresets, catalogUnavailable: Boolean(catalog.warning) } }
));
// Another path (e.g. send preflight) applied the catalog before the
// deferred idle callback scheduled in the visibility effect: clear its
// stale pending flag so the composer stops claiming models are loading.
@@ -1027,20 +1054,21 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
});
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
storedModelId,
agentModelMapRef.current[agentId],
runtimePresets,
);
if (normalizedStoredModelId && normalizedStoredModelId !== storedModelId) {
if (normalizedStoredModelId && normalizedStoredModelId !== agentModelMapRef.current[agentId]) {
setAgentModel(agentId, normalizedStoredModelId);
return;
}
const defaultModelId = resolveSdkRuntimeCatalogDefaultModelId(catalog, agent);
if (
options.adoptCurrentModel
&& catalog.currentModelId
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
&& defaultModelId
&& shouldAdoptSdkCurrentModel(defaultModelId, agentModelMapRef.current[agentId], runtimePresets, agent)
) {
setAgentModel(agentId, catalog.currentModelId);
setAgentModel(agentId, defaultModelId);
}
}, [setAgentModel, setRuntimeModelLoading, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
@@ -1081,13 +1109,25 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
return next;
});
return {
currentModelId: result.currentModelId ?? null,
models: result.models,
};
return toSdkRuntimeModelCatalog(result);
},
{ force: options.force },
).catch((err) => {
).then((catalog) => {
// A refresh that joined another panel's in-flight load never ran the
// loader above, so sync this panel's banner from the shared result.
setRuntimeModelWarnings((current) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
return reconcileRuntimeModelWarning(
current,
target.agentId,
target.cacheKey,
Boolean(catalog.warning),
t('ai.chat.modelCatalogWarning'),
);
});
return catalog;
}).catch((err) => {
setRuntimeModelWarnings((current) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
@@ -1114,6 +1154,16 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const cached = sdkRuntimeModelCache.read(target.cacheKey);
if (cached) {
applySdkRuntimeModelCatalog(target, cached);
// The cache stores warned (last-known-good) responses too: sync the
// banner so another panel or a remount within the TTL does not present
// degraded models as live.
setRuntimeModelWarnings((current) => reconcileRuntimeModelWarning(
current,
target.agentId,
target.cacheKey,
Boolean(cached.warning),
t('ai.chat.modelCatalogWarning'),
));
}
// Respect renderer TTL / in-flight coalescing for all SDK agents including
@@ -1130,10 +1180,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
};
let cancelled = false;
let idleFired = false;
// Mark the catalog pending as soon as the refresh is scheduled: otherwise
// `runtimeModelLoading` stays false for the whole idle delay (and longer
// while the composer stays focused), so the built-in OPENCODE_MODEL_PRESETS
// remain selectable the entire time (#3584). Only do this on a cache miss:
// Mark the catalog pending as soon as the refresh is scheduled so the
// composer can show its loading affordance for the whole idle delay (and
// longer while the composer stays focused) instead of silently keeping
// only the build-time presets (#3584). Only do this on a cache miss:
// when a cached catalog was already applied above, the runtime presets are
// available, so a pending flag would keep the composer banner/picker in the
// "loading" state indefinitely while the idle callback is deferred.
@@ -1169,8 +1219,34 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
buildExternalAgentRuntimeModelTarget,
loadSdkRuntimeModelCatalog,
applySdkRuntimeModelCatalog,
t,
]);
// Uniform status chip inputs: when a runtime catalog is active for the
// current agent its recorded provenance wins; otherwise the composer is
// showing build-time presets (the renderer-side preset fallback derives
// 'presets' here). While a load is pending the picker replaces the chip
// with the spinner row.
const modelCatalogSource = useMemo((): SdkRuntimeModelCatalogSource | undefined => {
// A config-locked Codex picker shows only that configured model, not the
// discovered catalog whose provenance is tracked below.
if (isCodexManagedAgent && codexConfigModel) return undefined;
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
const meta = runtimeModelCatalogMeta[currentAgentId];
const runtimeEntry = runtimeAgentModelPresets[currentAgentId];
if (meta && target && meta.cacheKey === target.cacheKey
&& runtimeEntry?.cacheKey === target.cacheKey && runtimeEntry.models.length > 0) {
return meta.source;
}
return 'presets';
}, [currentAgentConfig, currentAgentId, runtimeModelCatalogMeta, runtimeAgentModelPresets, buildExternalAgentRuntimeModelTarget, isCodexManagedAgent, codexConfigModel]);
const modelCatalogFetchedAt = useMemo((): string | undefined => {
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
const meta = runtimeModelCatalogMeta[currentAgentId];
if (meta && target && meta.cacheKey === target.cacheKey) return meta.fetchedAt;
return undefined;
}, [currentAgentConfig, currentAgentId, runtimeModelCatalogMeta, buildExternalAgentRuntimeModelTarget]);
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
@@ -1190,26 +1266,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (hasCodexCustomConfig && codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
if (runtimePresets) {
return appendComposerCustomModelPresets(
runtimePresets,
collectCustomModelIds(currentAgentId, runtimePresets),
);
}
// While an OpenCode catalog load is pending, do not flash the built-in
// OPENCODE_MODEL_PRESETS: they enumerate well-known gateway providers the
// user may never have configured, so for a few seconds every switch looks
// like a broken default model (#3584). Show only the user's own custom
// model ids until the live catalog resolves (or a retryable warning
// appears in the composer banner).
if (
target
&& getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
&& runtimeModelLoading[currentAgentId]?.cacheKey === target.cacheKey
) {
return appendComposerCustomModelPresets(
[],
collectCustomModelIds(currentAgentId, []),
if (runtimePresets?.length) {
return appendRetainedStoredModelPreset(
appendComposerCustomModelPresets(
runtimePresets,
collectCustomModelIds(currentAgentId, runtimePresets),
),
agentModelMap[currentAgentId],
currentAgentConfig,
Boolean(runtimeEntry?.catalogUnavailable),
);
}
const presets = getAgentModelPresets(
@@ -1221,15 +1286,22 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const filteredPresets = filterAgentModelPresetsForCliVersion(presets, cliVersion);
// Manual model ids typed in the composer (#3534) stay selectable even when
// the CLI catalog does not list them yet.
return appendComposerCustomModelPresets(
filteredPresets,
collectCustomModelIds(currentAgentId, filteredPresets),
return appendRetainedStoredModelPreset(
appendComposerCustomModelPresets(
filteredPresets,
collectCustomModelIds(currentAgentId, filteredPresets),
),
agentModelMap[currentAgentId],
currentAgentConfig,
// Until the initial catalog settles, show the choice a failed
// preflight would actually send instead of flashing Auto.
!runtimeEntry || !target || runtimeEntry.cacheKey !== target.cacheKey || runtimeEntry.catalogUnavailable,
);
}, [
currentAgentConfig,
currentAgentId,
runtimeAgentModelPresets,
runtimeModelLoading,
agentModelMap,
buildExternalAgentRuntimeModelTarget,
hasCodexCustomConfig,
codexConfigModel,
@@ -1454,17 +1526,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
getExternalAgentSdkBackend(currentAgentConfig),
),
);
const runtimePresets = appendComposerCustomModelPresets(
const runtimePresets = retainStoredModelPresetForCatalog(
catalogPresets,
collectCustomModelIds(sendAgentId, catalogPresets),
appendComposerCustomModelPresets(
catalogPresets,
collectCustomModelIds(sendAgentId, catalogPresets),
),
agentModelMapRef.current[sendAgentId],
currentAgentConfig,
Boolean(catalog.warning),
);
const storedModelId = agentModelMapRef.current[sendAgentId];
const defaultModelId = resolveSdkRuntimeCatalogDefaultModelId(catalog, currentAgentConfig);
if (
catalog.currentModelId
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
defaultModelId
&& shouldAdoptSdkCurrentModel(defaultModelId, storedModelId, runtimePresets, currentAgentConfig)
) {
sendSelectedAgentModel = catalog.currentModelId;
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets)) {
sendSelectedAgentModel = defaultModelId;
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets, currentAgentConfig)) {
sendSelectedAgentModel = normalizeStoredAgentModelSelection(
storedModelId,
runtimePresets,
@@ -1994,14 +2073,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
modelCatalogWarning={runtimeModelWarnings[currentAgentId]
modelCatalogWarning={!hasCodexCustomConfig
&& runtimeModelWarnings[currentAgentId]
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
? runtimeModelWarnings[currentAgentId].message
: undefined}
modelCatalogLoading={
getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
!hasCodexCustomConfig
&& Boolean(runtimeModelLoading[currentAgentId])
&& runtimeModelLoading[currentAgentId]?.cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
}
modelCatalogSource={modelCatalogSource}
modelCatalogFetchedAt={modelCatalogFetchedAt}
handleModelCatalogRetry={handleRetrySdkModelCatalog}
agentModelPresets={agentModelPresets}
// A managed Codex config's `model` field overrides every selection on
+1
View File
@@ -40,6 +40,7 @@ export interface AIChatSidePanelProps {
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
+369
View File
@@ -1,20 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
appendRetainedStoredModelPreset,
retainStoredModelPresetForCatalog,
buildCursorListModelsAgentEnv,
buildSdkRuntimeModelCacheKey,
canonicalizeEffortEncodedModelId,
createSdkRuntimeModelCache,
agentModelPresetsShallowEqual,
mergeFallbackThinkingLevels,
MODEL_CACHE_ENV_HINTS,
modelPresetsContainId,
normalizeStoredAgentModelSelection,
normalizeSdkRuntimeModelPresets,
resolveSdkRuntimeCatalogDefaultModelId,
reconcileRuntimeModelWarning,
toSdkRuntimeModelCatalog,
shouldAdoptSdkCurrentModel,
shouldLoadSdkRuntimeModels,
shouldUseStoredAgentModel,
} from './AIChatSidePanelHelpers';
import { createRequire } from 'node:module';
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
test('buildCursorListModelsAgentEnv injects cli-login auth mode for list-models', () => {
@@ -298,6 +306,157 @@ test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is retu
);
});
test('stored claude alias selections keep resolving against a full-id catalog', () => {
const claudeAgent: ExternalAgentConfig = {
id: 'managed_claude',
name: 'Claude Code',
command: 'claude',
enabled: true,
sdkBackend: 'claude',
};
const fullIdPresets: AgentModelPreset[] = [
{ id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' },
{ id: 'claude-haiku-5-5', name: 'Haiku 5.5' },
];
// Legacy persisted alias ids (pre-2026-10 preset refresh) still resolve for
// the claude backend, with or without an effort suffix.
assert.equal(shouldUseStoredAgentModel('sonnet', fullIdPresets, claudeAgent), true);
assert.equal(shouldUseStoredAgentModel('sonnet/high', fullIdPresets, claudeAgent), true);
// ...and are never silently replaced by the catalog's current model.
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'sonnet', fullIdPresets, claudeAgent), false);
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'sonnet/high', fullIdPresets, claudeAgent), false);
// Without agent context the previous behavior is unchanged.
assert.equal(shouldUseStoredAgentModel('sonnet', fullIdPresets), false);
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'sonnet', fullIdPresets), true);
// Non-claude backends and non-alias stored ids are untouched.
const opencodeAgent: ExternalAgentConfig = {
id: 'managed_opencode',
name: 'OpenCode',
command: 'opencode',
enabled: true,
sdkBackend: 'opencode',
};
assert.equal(shouldUseStoredAgentModel('sonnet', fullIdPresets, opencodeAgent), false);
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'gpt-5.5', fullIdPresets, claudeAgent), true);
});
test('a saved Copilot model survives the auto-only degraded fallback', () => {
const copilotAgent: ExternalAgentConfig = {
id: 'managed_copilot',
name: 'GitHub Copilot',
command: 'copilot',
enabled: true,
sdkBackend: 'copilot',
};
const autoOnlyFallback: AgentModelPreset[] = [{ id: 'auto', name: 'Auto' }];
// Live discovery failed (no last-known store yet): the saved explicit model
// must keep resolving instead of silently switching to `auto`.
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5', autoOnlyFallback, copilotAgent, true), true);
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5/high', autoOnlyFallback, copilotAgent, true), true);
// Other backends with the same shape are unaffected.
const opencodeAgent: ExternalAgentConfig = {
id: 'managed_opencode',
name: 'OpenCode',
command: 'opencode',
enabled: true,
sdkBackend: 'opencode',
};
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5', autoOnlyFallback, opencodeAgent), false);
// Composer custom ids are mixed into the same picker list; the degraded
// fallback must still be detected with extra rows present.
const mixedFallback: AgentModelPreset[] = [
{ id: 'auto', name: 'Auto', description: 'Automatic model selection' },
{ id: 'gpt-custom', name: 'gpt-custom' },
];
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5', mixedFallback, copilotAgent, true), true);
assert.equal(modelPresetsContainId(
appendRetainedStoredModelPreset(mixedFallback, 'claude-sonnet-4.5', copilotAgent, true),
'claude-sonnet-4.5',
), true);
// A real Copilot catalog (multiple entries with named rows) does not
// trigger the fallback, mixed or not.
assert.equal(
shouldUseStoredAgentModel('claude-sonnet-4.5', [
{ id: 'auto', name: 'Auto' },
{ id: 'gpt-5', name: 'GPT-5' },
], copilotAgent),
false,
);
assert.equal(
shouldUseStoredAgentModel('claude-sonnet-4.5', [
{ id: 'auto', name: 'Auto' },
{ id: 'gpt-5', name: 'GPT-5' },
{ id: 'gpt-custom', name: 'gpt-custom' },
], copilotAgent),
false,
);
});
test('appendRetainedStoredModelPreset makes preserved saved models visible', () => {
const claudeAgent: ExternalAgentConfig = {
id: 'managed_claude',
name: 'Claude Code',
command: 'claude',
enabled: true,
sdkBackend: 'claude',
};
const fullIdPresets: AgentModelPreset[] = [{ id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }];
// A stored claude alias absent from a full-id catalog gains a preset row
// (reusing the build-time preset so thinking levels come along).
const withAlias = appendRetainedStoredModelPreset(fullIdPresets, 'sonnet/high', claudeAgent);
assert.equal(withAlias.length, 2);
assert.equal(withAlias[1].id, 'sonnet');
assert.equal(modelPresetsContainId(withAlias, 'sonnet/high'), true);
// The copilot auto-only degraded fallback keeps a saved explicit model
// selectable as its own row.
const copilotAgent: ExternalAgentConfig = {
id: 'managed_copilot',
name: 'GitHub Copilot',
command: 'copilot',
enabled: true,
sdkBackend: 'copilot',
};
const withCopilot = appendRetainedStoredModelPreset(
[{ id: 'auto', name: 'Auto' }],
'claude-sonnet-4.5',
copilotAgent,
true,
);
assert.deepEqual(withCopilot[1], { id: 'claude-sonnet-4.5', name: 'claude-sonnet-4.5' });
// An effort-suffixed saved id keeps its full spelling so the appended row
// matches the selection the send preflight passes.
const withCopilotSuffix = appendRetainedStoredModelPreset(
[{ id: 'auto', name: 'Auto' }],
'claude-sonnet-4.5/high',
copilotAgent,
true,
);
assert.deepEqual(withCopilotSuffix[1], { id: 'claude-sonnet-4.5/high', name: 'claude-sonnet-4.5/high' });
assert.equal(modelPresetsContainId(withCopilotSuffix, 'claude-sonnet-4.5/high'), true);
// A base-only custom row (no thinking levels) does not cover the stored
// suffixed id, so the exact retained id is appended beside it.
const baseOnly: AgentModelPreset[] = [
{ id: 'auto', name: 'Auto' },
{ id: 'claude-sonnet-4.5', name: 'claude-sonnet-4.5' },
];
const besideBase = appendRetainedStoredModelPreset(baseOnly, 'claude-sonnet-4.5/high', copilotAgent, true);
assert.equal(besideBase.length, 3);
assert.equal(modelPresetsContainId(besideBase, 'claude-sonnet-4.5/high'), true);
// Listed or non-retained selections are untouched.
assert.equal(appendRetainedStoredModelPreset(fullIdPresets, 'claude-sonnet-5-5', claudeAgent), fullIdPresets);
assert.equal(appendRetainedStoredModelPreset(fullIdPresets, 'gpt-5.5', claudeAgent), fullIdPresets);
assert.equal(appendRetainedStoredModelPreset(fullIdPresets, undefined, claudeAgent), fullIdPresets);
});
test('SDK runtime model cache coalesces concurrent refreshes', async () => {
const cache = createSdkRuntimeModelCache();
let calls = 0;
@@ -440,3 +599,213 @@ test('SDK runtime model cache stays bounded under agent configuration churn', as
assert.equal(cache.read('agent:0'), null);
assert.equal(cache.read('agent:19')?.currentModelId, 'model-19');
});
test('SDK runtime model cache preserves degraded warnings until a successful retry', async () => {
const cache = createSdkRuntimeModelCache();
const degraded = {
currentModelId: 'auto',
models: [{ id: 'auto', name: 'Auto' }],
warning: 'Live discovery failed; serving last-known-good models',
};
assert.equal((await cache.refresh('copilot', async () => degraded)).warning, degraded.warning);
assert.equal(cache.read('copilot')?.warning, degraded.warning);
assert.equal((await cache.refresh('copilot', async () => {
assert.fail('A second panel should use the cached catalog within the TTL');
})).warning, degraded.warning);
await cache.refresh('copilot', async () => ({
currentModelId: 'auto',
models: [{ id: 'auto', name: 'Auto' }],
}), { force: true });
assert.equal(cache.read('copilot')?.warning, undefined);
});
// #3584 alignment: OpenCode must render build-time presets while its live
// catalog load is pending, exactly like every other backend. The old host
// code suppressed presets for opencode while `runtimeModelLoading` was set,
// leaving the picker empty until the pull resolved. Guard against it coming
// back: the preset fallback in the panel must not gate on opencode loading.
test('host preset fallback does not suppress presets for a loading opencode catalog', () => {
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
assert.doesNotMatch(source, /getExternalAgentSdkBackend\(currentAgentConfig\) === 'opencode'[\s\S]*?runtimeModelLoading/);
// Build-time presets remain the fallback whenever no runtime catalog is
// active for the agent (loading included).
assert.match(source, /const presets = getAgentModelPresets\(/);
});
test('reconcileRuntimeModelWarning adds the banner for a warned catalog and keeps unrelated agents', () => {
const current = { other: { cacheKey: 'k-other', message: 'x' } };
const next = reconcileRuntimeModelWarning(current, 'copilot', 'k1', true, 'warn');
assert.deepEqual(next, {
other: { cacheKey: 'k-other', message: 'x' },
copilot: { cacheKey: 'k1', message: 'warn' },
});
});
test('reconcileRuntimeModelWarning returns the same object when nothing changes', () => {
const current = { copilot: { cacheKey: 'k1', message: 'warn' } };
assert.equal(reconcileRuntimeModelWarning(current, 'copilot', 'k1', true, 'warn'), current);
assert.equal(reconcileRuntimeModelWarning({}, 'copilot', 'k1', false, 'warn').copilot, undefined);
});
test('reconcileRuntimeModelWarning clears only the banner that belongs to this cache key', () => {
const current = { copilot: { cacheKey: 'k1', message: 'warn' } };
assert.deepEqual(reconcileRuntimeModelWarning(current, 'copilot', 'k1', false, 'warn'), {});
// A banner that belongs to another cache key (another profile) is not ours to clear.
assert.equal(reconcileRuntimeModelWarning(current, 'copilot', 'k2', false, 'warn'), current);
});
test('renderer model cache env hints cover every key the main-process catalog cache fingerprints', () => {
// Both caches must split on the same profile-affecting environment: a key that
// only the main process knows lets the renderer serve one profile's catalog to another.
const require = createRequire(import.meta.url);
const { SDK_MODEL_CACHE_ENV_KEYS } = require('../electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs') as {
SDK_MODEL_CACHE_ENV_KEYS: string[];
};
const hints = new Set<string>(MODEL_CACHE_ENV_HINTS);
const missing = SDK_MODEL_CACHE_ENV_KEYS.filter((key) => !hints.has(key));
assert.deepEqual(missing, []);
});
test('toSdkRuntimeModelCatalog keeps the provenance main reported for a last-known-good serve', () => {
const catalog = toSdkRuntimeModelCatalog({
ok: true,
currentModelId: 'm-1',
models: [{ id: 'm-1', name: 'M1' }],
warning: 'live fetch failed',
source: 'last-known-good',
fetchedAt: '2026-10-10T08:00:00.000Z',
});
assert.deepEqual(catalog, {
currentModelId: 'm-1',
models: [{ id: 'm-1', name: 'M1' }],
warning: 'live fetch failed',
source: 'last-known-good',
fetchedAt: '2026-10-10T08:00:00.000Z',
});
});
test('toSdkRuntimeModelCatalog tolerates an older main without provenance or with unknown values', () => {
assert.deepEqual(
toSdkRuntimeModelCatalog({ ok: true, models: [{ id: 'a', name: 'A' }] }),
{ currentModelId: null, models: [{ id: 'a', name: 'A' }], warning: null },
);
const odd = toSdkRuntimeModelCatalog({
ok: true,
models: [],
source: 'something-else' as never,
fetchedAt: 42 as never,
});
assert.equal(odd.source, undefined);
assert.equal(odd.fetchedAt, undefined);
});
test('retainStoredModelPresetForCatalog leaves the built-in fallback list alone when no catalog arrived', () => {
const claudeAgent = { id: 'claude', command: 'claude', sdkBackend: 'claude' } as unknown as ExternalAgentConfig;
// The first Claude catalog request failed: nothing to extend. Appending only
// the saved alias would make a one-row list authoritative and hide the
// built-in presets, so the empty list must stay empty.
assert.deepEqual(retainStoredModelPresetForCatalog([], [], 'sonnet', claudeAgent), []);
});
test('retainStoredModelPresetForCatalog drops composer custom rows too when no catalog arrived', () => {
const claudeAgent = { id: 'claude', command: 'claude', sdkBackend: 'claude' } as unknown as ExternalAgentConfig;
// The callers wrap the empty catalog with the user's custom composer models, so
// the list is non-empty even though no catalog arrived. Stored as the runtime
// list it would still hide the built-in presets; the panel appends custom
// models on top of the fallback list itself.
const withCustom = [{ id: 'my-custom-model', name: 'my-custom-model' }];
assert.deepEqual(retainStoredModelPresetForCatalog([], withCustom, 'sonnet', claudeAgent), []);
});
test('retainStoredModelPresetForCatalog still retains a saved selection on top of a real catalog', () => {
const claudeAgent = { id: 'claude', command: 'claude', sdkBackend: 'claude' } as unknown as ExternalAgentConfig;
const catalog = [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }];
const retained = retainStoredModelPresetForCatalog(catalog, catalog, 'sonnet', claudeAgent);
assert.deepEqual(retained, appendRetainedStoredModelPreset(catalog, 'sonnet', claudeAgent));
});
test('catalog source adaptation preserves explicit Copilot failure retention', () => {
const agent = { id: 'copilot', command: 'copilot', sdkBackend: 'copilot' } as ExternalAgentConfig;
const catalog = toSdkRuntimeModelCatalog({
ok: true,
models: [{ id: 'auto', name: 'Auto' }],
warning: 'live discovery unavailable',
source: 'last-known-good',
fetchedAt: '2026-10-15T09:00:00.000Z',
});
const presets = retainStoredModelPresetForCatalog(
catalog.models, catalog.models, 'saved-model', agent, Boolean(catalog.warning),
);
assert.deepEqual(presets.map(model => model.id), ['auto', 'saved-model']);
assert.deepEqual(retainStoredModelPresetForCatalog(
catalog.models, catalog.models, 'saved-model', agent, false,
).map(model => model.id), ['auto']);
});
test('SDK runtime model cache keeps the retry warning when a degraded refresh retains the cached catalog', async () => {
let now = 1_000;
const cache = createSdkRuntimeModelCache({ now: () => now });
const key = 'copilot:/opt/bin/copilot';
await cache.refresh(key, async () => ({
currentModelId: 'gpt-5',
models: [{ id: 'gpt-5', name: 'GPT-5' }],
source: 'live',
fetchedAt: '2026-10-15T09:00:00.000Z',
}));
now = 2_000;
const retried = await cache.refresh(
key,
async () => ({ currentModelId: null, models: [], warning: 'catalog unavailable' }),
{ force: true },
);
assert.deepEqual(retried.models, [{ id: 'gpt-5', name: 'GPT-5' }]);
assert.equal(retried.warning, 'catalog unavailable');
assert.equal(retried.source, 'last-known-good');
assert.equal(retried.fetchedAt, '2026-10-15T09:00:00.000Z');
// Later reads retain the warning without extending the good catalog's age.
assert.equal(cache.read(key)?.warning, 'catalog unavailable');
assert.equal(cache.read(key)?.updatedAt, 1_000);
const nextRead = await cache.refresh(key, async () => { throw new Error('fresh cache should be reused'); });
assert.equal(nextRead.warning, 'catalog unavailable');
assert.equal(nextRead.source, 'last-known-good');
});
test('degraded Copilot selection survives custom rows while live auto-only remains authoritative', () => {
const agent: ExternalAgentConfig = { id: 'copilot', name: 'Copilot', command: 'copilot', enabled: true, sdkBackend: 'copilot' };
const customRows = [{ id: 'manual-model', name: 'manual-model' }];
const retained = appendRetainedStoredModelPreset(customRows, 'saved-model', agent, true);
assert.equal(modelPresetsContainId(retained, 'saved-model'), true);
assert.equal(shouldUseStoredAgentModel('saved-model', retained, agent, true), true);
assert.equal(shouldAdoptSdkCurrentModel('auto', 'saved-model', retained, agent), false);
const live = [{ id: 'auto', name: 'auto' }];
assert.equal(appendRetainedStoredModelPreset(live, 'saved-model', agent, false), live);
assert.equal(shouldUseStoredAgentModel('saved-model', live, agent, false), false);
assert.equal(shouldAdoptSdkCurrentModel('auto', 'saved-model', live, agent), true);
});
test('renderer catalogs separate supported credential and profile overrides', () => {
const agent = { id: 'agent', command: '/same/cli', sdkBackend: 'copilot' };
for (const name of ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_CONFIG_DIR', 'CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN', 'CODEBUDDY_INTERNET_ENVIRONMENT', 'XAI_API_KEY', 'GROK_CONFIG_DIR']) {
const key = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'fixture-profile-a' } });
assert.notEqual(key, buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'fixture-profile-b' } }), name);
assert.doesNotMatch(key, /fixture-profile-a/);
}
});
test('Copilot successful catalog without current id replaces an unlisted saved choice before failure', () => {
const agent: ExternalAgentConfig = { id: 'copilot', name: 'Copilot', command: 'copilot', enabled: true, sdkBackend: 'copilot' };
// Copilot returns an array, so IPC has no currentModelId even on success.
const live = { models: [{ id: 'auto', name: 'auto' }], currentModelId: null };
const next = resolveSdkRuntimeCatalogDefaultModelId(live, agent);
assert.equal(next, 'auto');
assert.equal(shouldAdoptSdkCurrentModel(next, 'saved-model', live.models, agent), true);
const laterFailure = appendRetainedStoredModelPreset(live.models, next, agent, true);
assert.deepEqual(laterFailure, live.models);
assert.equal(resolveSdkRuntimeCatalogDefaultModelId({ ...live, warning: 'unavailable' }, agent), null);
});
+222 -4
View File
@@ -1,4 +1,5 @@
import {
getAgentModelPresets,
resolveAgentModelSelection,
type AgentModelPreset,
type ExternalAgentConfig,
@@ -18,9 +19,22 @@ export {
modelPresetsContainId,
};
export type SdkRuntimeModelCatalogSource = 'live' | 'last-known-good' | 'presets';
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
models: AgentModelPreset[];
// Degraded loads (e.g. last-known-good serve) carry their warning so a
// cached hit in another panel or after a remount still shows the retry
// banner instead of presenting stale models as live.
warning?: string | null;
// Provenance of the models list: 'live' when the SDK fetch succeeded,
// 'last-known-good' when main served the persisted previous success.
// Absent (older main) behaves like a live fetch for display purposes.
source?: Exclude<SdkRuntimeModelCatalogSource, 'presets'>;
// ISO timestamp of the successful fetch backing this catalog (live fetch
// time, or the persisted last-known-good fetchedAt).
fetchedAt?: string;
};
export type SdkRuntimeModelCacheEntry = SdkRuntimeModelCatalog & {
@@ -41,7 +55,7 @@ const SDK_RUNTIME_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES = 64;
// Keep in sync with main-process SDK_MODEL_CACHE_ENV_KEYS: profile-affecting
// env must bust the renderer cache so we re-query after OpenCode config switches.
const MODEL_CACHE_ENV_HINTS = [
export const MODEL_CACHE_ENV_HINTS = [
'HOME',
'USERPROFILE',
'XDG_CONFIG_HOME',
@@ -49,6 +63,7 @@ const MODEL_CACHE_ENV_HINTS = [
'CLAUDE_CONFIG_DIR',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'CLAUDE_CODE_OAUTH_TOKEN',
'OPENCODE_BIN',
'OPENCODE_CONFIG',
@@ -64,15 +79,41 @@ const MODEL_CACHE_ENV_HINTS = [
'XDG_CACHE_HOME',
'CLAUDE_CODE_EXECUTABLE',
'CODEBUDDY_CODE_PATH',
'CODEBUDDY_API_KEY',
'CODEBUDDY_AUTH_TOKEN',
'CODEBUDDY_INTERNET_ENVIRONMENT',
'XAI_API_KEY',
'GROK_CONFIG_DIR',
'COPILOT_GITHUB_TOKEN',
'GH_TOKEN',
'GITHUB_TOKEN',
'GH_CONFIG_DIR',
'CURSOR_API_KEY',
'NETCATTY_CURSOR_AUTH_MODE',
'NETCATTY_CURSOR_CLI_BIN',
] as const;
const SDK_RUNTIME_MODEL_CATALOG_SOURCES = new Set(['live', 'last-known-good']);
function normalizeCatalogSource(
source: unknown,
): SdkRuntimeModelCatalog['source'] {
return typeof source === 'string' && SDK_RUNTIME_MODEL_CATALOG_SOURCES.has(source)
? source as SdkRuntimeModelCatalog['source']
: undefined;
}
function normalizeCatalogFetchedAt(fetchedAt: unknown): string | undefined {
return typeof fetchedAt === 'string' && fetchedAt ? fetchedAt : undefined;
}
function cloneCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
return {
currentModelId: catalog.currentModelId ?? null,
models: [...catalog.models],
...(catalog.warning ? { warning: catalog.warning } : {}),
...(catalog.source ? { source: catalog.source } : {}),
...(catalog.fetchedAt ? { fetchedAt: catalog.fetchedAt } : {}),
};
}
@@ -82,6 +123,36 @@ function normalizeSdkRuntimeModelCatalog(catalog: SdkRuntimeModelCatalog): SdkRu
models: Array.isArray(catalog.models)
? catalog.models.filter((model): model is AgentModelPreset => Boolean(model?.id))
: [],
...(catalog.warning ? { warning: catalog.warning } : {}),
...(normalizeCatalogSource(catalog.source) ? { source: normalizeCatalogSource(catalog.source) } : {}),
...(normalizeCatalogFetchedAt(catalog.fetchedAt) ? { fetchedAt: normalizeCatalogFetchedAt(catalog.fetchedAt) } : {}),
};
}
/** The list-models IPC result as the renderer receives it. */
export type SdkListModelsResult = {
ok: boolean;
models?: AgentModelPreset[];
currentModelId?: string | null;
warning?: string;
error?: string;
// Provenance reported by main (absent on an older main).
source?: Exclude<SdkRuntimeModelCatalogSource, 'presets'>;
fetchedAt?: string;
};
/**
* Adapt a successful list-models result to a catalog while keeping the
* provenance main reported. Dropping source/fetchedAt would make the picker
* label a persisted last-known-good list as a live catalog.
*/
export function toSdkRuntimeModelCatalog(result: SdkListModelsResult): SdkRuntimeModelCatalog {
return {
currentModelId: result.currentModelId ?? null,
models: result.models ?? [],
warning: result.warning ?? null,
...(normalizeCatalogSource(result.source) ? { source: normalizeCatalogSource(result.source) } : {}),
...(normalizeCatalogFetchedAt(result.fetchedAt) ? { fetchedAt: normalizeCatalogFetchedAt(result.fetchedAt) } : {}),
};
}
@@ -187,7 +258,16 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
.then((catalog) => {
const normalized = normalizeSdkRuntimeModelCatalog(catalog);
if (normalized.models.length === 0 && !normalized.currentModelId) {
return cached ? cloneCatalog(cached) : cloneCatalog(normalized);
// A degraded retry keeps the older catalog usable but must still
// report its own warning, or the banner and Retry would vanish.
if (!cached) return cloneCatalog(normalized);
const retained = cloneCatalog(cached);
if (normalized.warning) {
retained.warning = normalized.warning;
retained.source = 'last-known-good';
touchEntry(key, { ...retained, updatedAt: cached.updatedAt });
}
return retained;
}
touchEntry(key, { ...cloneCatalog(normalized), updatedAt: now() });
pruneEntries();
@@ -207,6 +287,32 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
export type RuntimeModelWarnings = Record<string, { cacheKey: string; message: string }>;
/**
* Make the per-agent catalog warning banner match a catalog result. Used both
* for cache reads and after a refresh settles: a refresh that coalesced onto
* another panel's in-flight load never runs its own loader callback, so
* the caller must reconcile the banner from the shared result itself.
* Returns the same object when nothing changes (no extra render). A banner
* that belongs to a different cache key is never cleared.
*/
export function reconcileRuntimeModelWarning(
current: RuntimeModelWarnings,
agentId: string,
cacheKey: string,
hasWarning: boolean,
message: string,
): RuntimeModelWarnings {
const existing = current[agentId];
if (hasWarning) {
return existing?.cacheKey === cacheKey ? current : { ...current, [agentId]: { cacheKey, message } };
}
if (existing?.cacheKey !== cacheKey) return current;
const { [agentId]: _removed, ...rest } = current;
return rest;
}
export function mergeFallbackThinkingLevels(
runtime: AgentModelPreset[],
fallbacks: AgentModelPreset[],
@@ -278,12 +384,71 @@ export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean
|| sdkBackend === 'mimo';
}
// Alias ids the claude CLI itself still resolves: its alias table maps
// opus/sonnet/haiku -> claude-*-5-5 and fable -> claude-fable-5-1. Preset
// lists before the 2026-10 catalog refresh persisted `sonnet`/`haiku` as
// preset ids, so a stored alias must keep resolving instead of being
// silently replaced by the live catalog's full id.
const CLAUDE_CLI_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']);
function isStillValidClaudeAlias(
storedModelId: string | null | undefined,
agent?: ExternalAgentConfig,
): boolean {
if (!storedModelId) return false;
if (getExternalAgentSdkBackend(agent) !== 'claude') return false;
const baseId = canonicalizeEffortEncodedModelId(storedModelId).split('/')[0];
return CLAUDE_CLI_MODEL_ALIASES.has(baseId);
}
// Retain unlisted Copilot choices while discovery is pending or failed.
// A successful auto-only catalog is authoritative, regardless of row names.
function shouldRetainUnlistedCopilotModel(
agent: ExternalAgentConfig | undefined,
retainUnlistedModel: boolean,
): boolean {
return retainUnlistedModel && getExternalAgentSdkBackend(agent) === 'copilot';
}
/**
* True when a stored selection still resolves even though it is absent from
* the given presets: either the presets list it under an effort-suffixed
* spelling, or it is a claude CLI alias the backend accepts verbatim.
*/
export function storedModelStillResolvesForAgent(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
agent?: ExternalAgentConfig,
): boolean {
if (!storedModelId) return false;
return modelPresetsContainId(presets, storedModelId)
|| isStillValidClaudeAlias(storedModelId, agent);
}
export function resolveSdkRuntimeCatalogDefaultModelId(
catalog: SdkRuntimeModelCatalog,
agent?: ExternalAgentConfig,
): string | null {
if (catalog.currentModelId) return catalog.currentModelId;
// Copilot lists models without a current id. Commit the same visible
// default on live success, so a later failed refresh cannot revive an
// unlisted saved choice that the live catalog has already replaced.
if (!catalog.warning && getExternalAgentSdkBackend(agent) === 'copilot' && catalog.models[0]) {
return resolveAgentModelSelection(catalog.models[0]);
}
return null;
}
export function shouldAdoptSdkCurrentModel(
currentModelId: string | null | undefined,
storedModelId: string | null | undefined,
runtimePresets: AgentModelPreset[],
agent?: ExternalAgentConfig,
): boolean {
if (!currentModelId) return false;
// A stored claude alias the CLI still resolves is a user choice: never
// overwrite it with the catalog's current model.
if (isStillValidClaudeAlias(storedModelId, agent)) return false;
return !storedModelId
|| runtimePresets.length === 0
|| !modelPresetsContainId(runtimePresets, storedModelId);
@@ -302,10 +467,63 @@ export function shouldUseStoredAgentModel(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
agent?: ExternalAgentConfig,
retainUnlistedModel = false,
): boolean {
if (!storedModelId) return false;
return modelPresetsContainId(presets, storedModelId)
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent));
return storedModelStillResolvesForAgent(storedModelId, presets, agent)
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent))
|| shouldRetainUnlistedCopilotModel(agent, retainUnlistedModel);
}
/**
* A stored selection preserved through the claude-alias or copilot
* degraded fallback has no preset row of its own; append one (reusing the
* build-time preset when it defines the id) so the picker can render and
* check the selection while the send preflight passes the same value.
*/
export function appendRetainedStoredModelPreset(
presets: AgentModelPreset[],
storedModelId: string | null | undefined,
agent?: ExternalAgentConfig,
retainUnlistedModel = false,
): AgentModelPreset[] {
if (!storedModelId || modelPresetsContainId(presets, storedModelId)) return presets;
if (!isStillValidClaudeAlias(storedModelId, agent)
&& !shouldRetainUnlistedCopilotModel(agent, retainUnlistedModel)) {
return presets;
}
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
const baseId = canonical.split('/')[0];
if (presets.some((preset) => preset.id === baseId)) {
// The base row did not cover the stored id (checked above), so a suffixed
// selection needs its own exact row; a bare id is already present.
return canonical === baseId ? presets : [...presets, { id: canonical, name: canonical }];
}
const fallback = getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent))
.find((preset) => preset.id === baseId);
// Keep the exact stored id (including any effort suffix) so the appended
// row still matches the selection the send preflight passes.
return [...presets, fallback ?? { id: canonical, name: canonical }];
}
/**
* Retain the saved selection on top of the presets built for a catalog, but
* only when a catalog actually arrived. When none did (the request failed
* before any last-known-good entry existed) the result is empty so the agent
* keeps its whole built-in fallback list: a lone retained row, or only the
* user's custom composer models, would be stored as the authoritative runtime
* list and hide every built-in preset. The panel appends custom models on top
* of the fallback list itself.
*/
export function retainStoredModelPresetForCatalog(
catalogPresets: AgentModelPreset[],
presets: AgentModelPreset[],
storedModelId: string | null | undefined,
agent?: ExternalAgentConfig,
retainUnlistedModel = false,
): AgentModelPreset[] {
if (catalogPresets.length === 0) return [];
return appendRetainedStoredModelPreset(presets, storedModelId, agent, retainUnlistedModel);
}
export function generateId(): string {
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
test('provider disconnect waits for completion, reports failure and permits retry', async () => {
const dom = installDomEnvironment();
const globals = {
localStorage: dom.window.localStorage,
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
const { ProviderCard } = await import('./cloud-sync/CloudSyncControls');
const { toast } = await import('./ui/toast');
const renderer = await createDomRenderer(dom.document);
const notices: string[] = [];
const info = toast.info;
const error = toast.error;
toast.info = (message) => { notices.push(`info:${message}`); };
toast.error = (message) => { notices.push(`error:${message}`); };
let finish: (() => void) | undefined;
let fail = true;
let calls = 0;
try {
await renderer.render(
<I18nProvider locale="en">
<ProviderCard provider="webdav" name="WebDAV" icon={null} isConnected isSyncing={false}
onConnect={() => {}} onSync={() => {}}
onDisconnect={() => {
calls += 1;
if (fail) throw new Error('fixture failure');
return new Promise<void>((resolve) => { finish = resolve; });
}} />
</I18nProvider>,
);
const click = async (node: Element) => {
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
await flushEffects();
};
const button = dom.document.querySelector('button[aria-label="Disconnect"]');
assert.ok(button);
assert.equal(button.textContent, 'Disconnect');
await click(button);
let dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
assert.match(dialog.textContent ?? '', /backup history and sync encryption password stay here/);
const confirm = () => {
const node = [...dom.document.querySelectorAll('[role="dialog"] button')].find((el) => el.textContent === 'Disconnect');
assert.ok(node);
return node as HTMLButtonElement;
};
await click(confirm());
assert.equal(calls, 1);
assert.equal(notices.length, 1);
assert.match(notices[0], /^error:Could not finish disconnecting from WebDAV/);
assert.ok(dom.document.querySelector('[role="dialog"]'));
assert.equal(confirm().disabled, false);
fail = false;
await click(confirm());
assert.equal(calls, 2);
assert.equal(confirm().disabled, true);
assert.equal(notices.length, 1);
await runWithAct(async () => { finish?.(); });
await flushEffects();
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
assert.equal(notices.length, 2);
assert.match(notices[1], /^info:Encrypted sync copies may still exist in your WebDAV storage/);
assert.doesNotMatch(notices[1], /\{name\}/);
// Cancelling a new confirmation never calls the disconnect callback.
await click(button);
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
const cancel = [...dialog.querySelectorAll('button')].find((el) => el.textContent === 'Cancel');
assert.ok(cancel);
await click(cancel);
assert.equal(calls, 2);
} finally {
toast.info = info;
toast.error = error;
await renderer.unmount();
// Radix restores focus on a timer after unmount. Keep the DOM globals
// installed until that cleanup has dispatched its final event.
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
+35 -1
View File
@@ -27,8 +27,9 @@ import {
toGlobalHistoryDisplayEntries,
} from '../domain/globalHistory';
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore';
import { removeCommandHistoryEntry } from '../application/state/commandHistoryStore';
import { cn } from '../lib/utils';
import { ConfirmDialog } from './ui/confirm-dialog';
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
import {
VariableSizeVirtualList,
@@ -46,6 +47,7 @@ export interface HistorySidePanelProps {
globalEntries: ShellHistoryEntry[];
onFetch: (sessionId: string, hostId: string) => void;
onDeleteGlobalEntry?: (entryId: string) => void;
onClearGlobalHistory?: () => void;
/** Paste into the terminal without executing (no trailing Enter). */
onPasteToTerminal: (command: string) => void;
/** Write to the terminal and execute (append Enter). */
@@ -115,6 +117,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
globalEntries,
onFetch,
onDeleteGlobalEntry,
onClearGlobalHistory,
onPasteToTerminal,
onRunInTerminal,
isVisible = true,
@@ -123,6 +126,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
const [scope, setScope] = useState<HistoryPanelScope>('host');
const [search, setSearch] = useState('');
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
const [clearAllOpen, setClearAllOpen] = useState(false);
const listRef = useRef<VariableSizeVirtualListHandle>(null);
const protocol = focusedHost?.protocol;
@@ -208,6 +212,14 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
setSelectedEntryId(null);
}, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]);
const handleClearAll = useCallback(() => {
// The application-state action clears both the global shell history and the
// autocomplete history, and fans the clear out to every renderer.
onClearGlobalHistory?.();
setClearAllOpen(false);
setSelectedEntryId(null);
}, [onClearGlobalHistory]);
const handleRowClick = useCallback((entryId: string) => {
setSelectedEntryId((current) => {
const next = current === entryId ? null : entryId;
@@ -294,6 +306,18 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
</button>
)}
{scope === 'global' && (
<button
type="button"
onClick={() => setClearAllOpen(true)}
disabled={!onClearGlobalHistory}
title={t('history.action.clearAll')}
aria-label={t('history.action.clearAll')}
className="shrink-0 h-6 w-6 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
@@ -411,6 +435,16 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
/>
)}
</div>
<ConfirmDialog
open={clearAllOpen}
onOpenChange={setClearAllOpen}
title={t('history.clearAll.dialog.title')}
message={t('history.clearAll.dialog.desc')}
confirmLabel={t('history.clearAll.dialog.confirm')}
destructive
onConfirm={handleClearAll}
/>
</div>
);
};
+2
View File
@@ -239,6 +239,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onAddKnownHost,
onCommandExecuted,
onDeleteShellHistoryEntry,
onClearCommandHistory,
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
@@ -2533,6 +2534,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleCloseSidePanel,
handleCommandExecuted,
handleHistoryDelete: onDeleteShellHistoryEntry,
handleHistoryClear: onClearCommandHistory,
handleCommandSubmitted,
handleComposeSend,
handleHistoryPaste,
+1 -1
View File
@@ -107,7 +107,7 @@ export const TopTabsQuickControls: React.FC<TopTabsQuickControlsProps> = ({
onClick={() => setIsOpacityExpanded((current) => !current)}
>
<div className="flex min-w-0 items-center gap-2 text-sm">
<Droplets size={14} className="shrink-0 text-muted-foreground" />
<Droplets size={14} className={cn("shrink-0", windowOpacity < 1.0 ? "text-primary" : "text-muted-foreground")} />
<span className="truncate">{t('topTabs.windowOpacity')}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
+13 -8
View File
@@ -51,6 +51,7 @@ import { useStoredString } from "../application/state/useStoredString";
import type { VaultLockHandle } from "../application/state/vaultManagedImportLock";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
import { useVaultHostDeletion } from "../application/state/useVaultHostDeletion";
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
import type {
VaultGroupMutationResult,
@@ -980,15 +981,19 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsMultiSelectMode(false);
}, []);
const deleteSelectedHosts = useCallback(() => {
const deleteVaultHosts = useVaultHostDeletion(onCommitVaultGroupMutation);
const deleteSelectedHosts = useCallback(async () => {
if (selectedHostIds.size === 0) return;
const updatedHosts = hosts.filter((h) => !selectedHostIds.has(h.id));
onUpdateHosts(updatedHosts);
clearHostSelection();
toast.success(
t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }),
);
}, [selectedHostIds, hosts, onUpdateHosts, clearHostSelection, t]);
try {
await deleteVaultHosts(selectedHostIds);
clearHostSelection();
toast.success(
t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }),
);
} catch {
toast.error(t("common.error"));
}
}, [selectedHostIds, deleteVaultHosts, clearHostSelection, t]);
const connectSelectedHosts = useCallback(() => {
if (selectedHostIds.size === 0) return;
+14 -3
View File
@@ -206,8 +206,12 @@ interface ChatInputProps {
placeholder?: string;
/** Available model presets for the current agent */
modelPresets?: AgentModelPreset[];
/** Live agent model catalog (OpenCode) is still loading. */
/** Live agent model catalog is still loading. */
modelCatalogLoading?: boolean;
/** Provenance of the active model list, rendered as the picker status chip. */
modelCatalogSource?: 'live' | 'last-known-good' | 'presets';
/** ISO timestamp of the successful fetch backing `modelCatalogSource`. */
modelCatalogFetchedAt?: string;
/**
* Offer the picker's "use custom model" action. Disable when the current
* agent locks the model (e.g. managed Codex `model` config overrides the
@@ -280,6 +284,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
placeholder,
modelPresets = [],
modelCatalogLoading = false,
modelCatalogSource,
modelCatalogFetchedAt,
allowCustomModelEntry = true,
selectedModelId,
onModelSelect,
@@ -1000,8 +1006,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
? providerSwitcherChipLabel
: (selectedPreset?.name || modelName || providerName || t('ai.chat.noModel'));
const modelChipMaxWidth = hasProviderSwitcher ? 'max-w-[168px]' : 'max-w-[96px]';
// Keep the picker open-able while an OpenCode catalog load is pending so
// the user sees the loading row instead of stale built-in presets (#3584).
// Keep the picker open-able while an SDK catalog load is pending: the host
// now shows build-time presets during loading, with the loading row when
// presets are still empty (#3584).
const hasModelPicker = hasProviderSwitcher
|| ((modelPresets.length > 0 || modelCatalogLoading) && !!onModelSelect);
const thinkingLevels = useMemo(
@@ -1599,6 +1606,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
selectedModelId={hasProviderSwitcher ? providerSwitcher?.selectedModelId : selectedBaseModelId}
modelPresets={hasProviderSwitcher ? undefined : modelPresets}
loading={!hasProviderSwitcher ? modelCatalogLoading : undefined}
catalogSource={!hasProviderSwitcher ? modelCatalogSource : undefined}
catalogFetchedAt={!hasProviderSwitcher ? modelCatalogFetchedAt : undefined}
allowCustomEntry={allowCustomModelEntry}
prefs={modelPrefs}
onSelectProviderModel={(providerId, modelId, contextWindow) => {
@@ -1813,6 +1822,8 @@ function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boo
&& prev.placeholder === next.placeholder
&& prev.modelPresets === next.modelPresets
&& prev.modelCatalogLoading === next.modelCatalogLoading
&& prev.modelCatalogSource === next.modelCatalogSource
&& prev.modelCatalogFetchedAt === next.modelCatalogFetchedAt
&& prev.allowCustomModelEntry === next.allowCustomModelEntry
&& prev.selectedModelId === next.selectedModelId
&& prev.onModelSelect === next.onModelSelect
+42
View File
@@ -22,6 +22,48 @@ const makeMessage = (index: number): ChatMessage => ({
timestamp: index,
});
for (const field of ["attachments", "images"] as const) {
test(`ChatMessageList shows unavailable restored ${field} without broken image previews`, () => {
const messages: ChatMessage[] = [{
id: "restored-user", role: "user", content: "restored request", timestamp: 1,
[field]: [
{ base64Data: "", contentUnavailable: true, mediaType: "image/png", filename: "screenshot.png" },
{ base64Data: "", contentUnavailable: true, mediaType: "application/pdf", filename: "report.pdf" },
{ base64Data: "", contentUnavailable: true, mediaType: "text/plain", filename: "selection.log", terminalSelection: true },
],
}];
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider><ChatMessageList messages={messages} /></TooltipProvider>
</I18nProvider>,
);
assert.match(markup, /screenshot\.png/);
assert.match(markup, /report\.pdf/);
assert.match(markup, /selection\.log/);
assert.match(markup, /Content not available on this device/);
assert.doesNotMatch(markup, /<img|data:image/);
});
}
test("ChatMessageList retains local image previews and synced note identities", () => {
const messages: ChatMessage[] = [{
id: "user", role: "user", content: "look", timestamp: 1,
attachments: [
{ base64Data: "cGljdHVyZQ==", mediaType: "image/png", filename: "local.png" },
{ base64Data: "", contentUnavailable: true, mediaType: "text/markdown", vaultNoteId: "note-1", vaultNoteTitle: "Runbook" },
],
}];
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider><ChatMessageList messages={messages} /></TooltipProvider>
</I18nProvider>,
);
assert.match(markup, /<img/);
assert.match(markup, /data:image\/png;base64,cGljdHVyZQ==/);
assert.match(markup, /Runbook/);
assert.doesNotMatch(markup, /Content not available on this device/);
});
test("resolved approval state retains only tool calls still present in messages", () => {
const previous = new Map<string, boolean>();
for (let index = 0; index < 1_000; index += 1) previous.set(`old-${index}`, true);
+13 -1
View File
@@ -710,7 +710,19 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
{isUser && !hideAttachments && (message.attachments ?? message.images)?.length && (
<div className="flex gap-1.5 flex-wrap mb-1">
{(message.attachments ?? message.images)!.map((att, i) => (
att.terminalSelection ? (
!att.vaultNoteId && (att.contentUnavailable || !att.base64Data) ? (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 min-h-7 px-2 py-1 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
title={t('ai.chat.attachmentContentUnavailable')}
>
{att.terminalSelection
? <SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
: <FileText size={12} className="text-muted-foreground/60 shrink-0" />}
<span className="truncate max-w-[150px]">{att.filename || (att.terminalSelection ? 'terminal selection' : 'file')}</span>
<span className="text-muted-foreground">{t('ai.chat.attachmentContentUnavailable')}</span>
</div>
) : att.terminalSelection ? (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
+31 -1
View File
@@ -5,6 +5,35 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ComposerModelPicker } from './ComposerModelPicker';
import { I18nProvider } from '../../application/i18n/I18nProvider';
for (const [locale, cachedLabel] of [
['en', 'Cached'],
['zh-CN', '缓存'],
['zh-TW', '快取'],
['es', 'En caché'],
['ru', 'Кэш'],
]) {
test(`cached catalog without a usable timestamp has a clean label in ${locale}`, () => {
for (const fetchedAt of [undefined, 'invalid-date']) {
const html = renderToStaticMarkup(
<I18nProvider locale={locale}>
<ComposerModelPicker
modelPresets={[{ id: 'saved-model', name: 'Saved model' }]}
selectedModelId="saved-model"
catalogSource="last-known-good"
catalogFetchedAt={fetchedAt}
prefs={{ recent: [], pinned: [] }}
onSelectModel={() => {}}
onTogglePinned={() => {}}
/>
</I18nProvider>,
);
assert.ok(html.includes(`title="${cachedLabel}"`));
assert.doesNotMatch(html, /\{time\}|ai\.chat\.modelCatalog/);
}
});
}
test('Catty picker keeps a single model list and hides other providers until the submenu opens', () => {
const html = renderToStaticMarkup(
@@ -123,6 +152,7 @@ test('external agent picker shows the loading row while the live catalog is pend
);
assert.match(html, /ai\.chat\.loadingModels/);
// The host no longer renders built-in presets while loading (#3584).
// With no presets the picker renders only the loading row; the host now
// keeps built-in presets visible during loading and passes them through.
assert.doesNotMatch(html, /gpt-5\.1/);
});
+33
View File
@@ -11,6 +11,7 @@ import {
type ComposerPickerModel,
} from '../../infrastructure/ai/composerPicker';
import type { AgentModelPreset, ProviderConfig } from '../../infrastructure/ai/types';
import { formatRelativeTime } from '../AIChatSessionHistoryDrawer';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { useProviderModelCatalog } from './useProviderModelCatalog';
@@ -27,6 +28,15 @@ export interface ComposerModelPickerProps {
* loading. Show the loading row instead of an empty/placeholder list.
*/
loading?: boolean;
/**
* Provenance of the model list behind this picker: a live SDK fetch, the
* persisted last-known-good catalog, or the build-time preset fallback.
* Rendered as a small muted status footer; omit (provider switcher mode)
* to hide the chip.
*/
catalogSource?: 'live' | 'last-known-good' | 'presets';
/** ISO timestamp of the successful fetch backing `catalogSource`. */
catalogFetchedAt?: string;
prefs: ComposerModelPrefs;
/**
* Offer the "use custom model" action. Disable when the host locks the
@@ -95,6 +105,8 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
selectedModelId,
modelPresets = [],
loading = false,
catalogSource,
catalogFetchedAt,
prefs,
allowCustomEntry = true,
onSelectProviderModel,
@@ -147,6 +159,21 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
);
const trimmedQuery = query.trim();
// Uniform catalog status chip (all SDK backends). While the live fetch is
// in flight the spinner row replaces the chip, so it renders only for a
// resolved list; an unknown source renders nothing (provider mode).
const catalogChipText = useMemo(() => {
if (loading || !catalogSource) return null;
if (catalogSource === 'live') return t('ai.chat.modelCatalogSourceLive');
if (catalogSource === 'presets') return t('ai.chat.modelCatalogSourcePresets');
const parsed = catalogFetchedAt ? new Date(catalogFetchedAt) : null;
const time = parsed && !Number.isNaN(parsed.getTime())
? formatRelativeTime(parsed, t)
: '';
return time
? t('ai.chat.modelCatalogSourceCached').replace('{time}', time)
: t('ai.chat.modelCatalogSourceCachedNoTime');
}, [loading, catalogSource, catalogFetchedAt, t]);
// Manual model ids are accepted in both modes: provider-bound catalogs and
// preset lists from external CLI agents whose catalogs lag new models.
const showCustom = Boolean(
@@ -356,6 +383,12 @@ export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
</div>
)}
</div>
{catalogChipText && (
<div className="border-t border-border/40 px-2.5 py-1 text-[10px] text-muted-foreground/45" title={catalogChipText}>
{catalogChipText}
</div>
)}
</div>
);
};
+58
View File
@@ -1,6 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildExternalBridgeContextMessages } from "../../infrastructure/ai/harness/externalBridgeContext.ts";
import { ToolOutputStore } from "../../infrastructure/ai/harness/toolOutputStore.ts";
import {
buildCattySdkMessages,
createContinuationContext,
} from "../../infrastructure/ai/harness/turnDrivers/cattyMessageBuilder.ts";
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
import {
@@ -231,6 +236,59 @@ test("buildHistoricalUserReplayContent replaces historical vault note mentions w
assert.doesNotMatch(result, /AAAAAA/);
});
for (const field of ["attachments", "images"] as const) {
test(`restored ${field} keep image, file and terminal metadata usable when continuing a chat`, () => {
const attachments: ChatMessageAttachment[] = [
{ base64Data: "", contentUnavailable: true, mediaType: "image/png", filename: "screenshot.png" },
{ base64Data: "", contentUnavailable: true, mediaType: "application/pdf", filename: "report.pdf" },
{ base64Data: "", contentUnavailable: true, mediaType: "text/plain", filename: "selection.log", terminalSelection: true, lineCount: 3 },
];
const messages: ChatMessage[] = [{
id: "restored-user", role: "user", content: "Inspect these files", timestamp: 1,
[field]: attachments,
}];
const catty = buildCattySdkMessages({
allMessages: messages,
includeCurrentUserMessage: true,
trimmed: "Please continue",
continuationContext: createContinuationContext("provider", "openai", "model", false),
chatSessionId: "restored-chat",
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
const external = buildExternalBridgeContextMessages(messages);
for (const history of [catty, external]) {
const content = String(history.find((message) => message.role === "user")?.content);
assert.match(content, /screenshot\.png/);
assert.match(content, /report\.pdf/);
assert.match(content, /selection\.log/);
assert.match(content, /lines=3/);
assert.equal(content.match(/content unavailable on this device/g)?.length, 3);
assert.doesNotMatch(content, /0 base64 chars|undefined/);
}
assert.equal(catty.at(-1)?.content, "Please continue");
});
}
test("synced note metadata retains the reference needed to read the note", () => {
const replay = buildHistoricalUserReplayContent("check this note", [{
base64Data: "", contentUnavailable: true, mediaType: "text/markdown",
vaultNoteId: "note-123", vaultNoteTitle: "Runbook",
}]);
assert.match(replay, /"noteId":"note-123"/);
assert.match(replay, /vault_notes_get/);
});
test("unavailable attachment placeholders do not expose stale local content fields", () => {
const replay = buildHistoricalUserReplayContent("check", [{
base64Data: "", contentUnavailable: true, mediaType: "text/plain", terminalSelection: true,
previewText: "private terminal output",
}, {
base64Data: "", contentUnavailable: true, mediaType: "application/pdf", filePath: "/private/report.pdf",
}]);
assert.doesNotMatch(replay, /private terminal output|\/private\//);
});
test("external recovery retains note identity before truncating a long user request", () => {
const history = buildExternalBridgeContextMessages([{
id: "long-note-request", role: "user", timestamp: 1, content: "x".repeat(2500),
+5 -2
View File
@@ -15,6 +15,9 @@ function truncateInline(value: string, maxChars: number): string {
}
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
if (attachment.contentUnavailable || !attachment.base64Data) {
return "content unavailable on this device";
}
return `${attachment.base64Data.length} base64 chars`;
}
@@ -25,7 +28,7 @@ function formatTerminalSelectionPlaceholder(
const details = [
`filename=${attachment.filename || `terminal-selection-${index + 1}.log`}`,
attachment.lineCount != null ? `lines=${attachment.lineCount}` : undefined,
attachment.previewText ? `preview=${truncateInline(attachment.previewText, MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS)}` : undefined,
!attachment.contentUnavailable && attachment.previewText ? `preview=${truncateInline(attachment.previewText, MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS)}` : undefined,
describeAttachmentSize(attachment),
].filter(Boolean).join(", ");
@@ -39,7 +42,7 @@ function formatAttachmentPlaceholder(
const label = attachment.mediaType.startsWith("image/") ? "image" : "file";
const details = [
attachment.filename ? `filename=${attachment.filename}` : undefined,
attachment.filePath ? `path=${attachment.filePath}` : undefined,
!attachment.contentUnavailable && attachment.filePath ? `path=${attachment.filePath}` : undefined,
`mediaType=${attachment.mediaType}`,
describeAttachmentSize(attachment),
].filter(Boolean).join(", ");
+24 -5
View File
@@ -279,7 +279,7 @@ interface ProviderCardProps {
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void;
onDisconnect: () => void | Promise<void>;
onSync: () => void;
extraActions?: React.ReactNode;
}
@@ -304,6 +304,7 @@ export const ProviderCard: React.FC<ProviderCardProps> = ({
}) => {
const { t } = useI18n();
const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const formatLastSyncLabel = (timestamp?: number): string => {
if (!timestamp) return t('cloudSync.lastSync.never');
const now = Date.now();
@@ -411,10 +412,12 @@ export const ProviderCard: React.FC<ProviderCardProps> = ({
size="sm"
variant="ghost"
onClick={() => setDisconnectConfirmOpen(true)}
className="text-muted-foreground hover:text-red-500"
disabled={disconnecting}
className="gap-1 text-muted-foreground hover:text-red-500"
aria-label={t('cloudSync.provider.disconnect')}
>
<CloudOff size={14} />
{t('cloudSync.provider.disconnect')}
</Button>
</>
) : isConnecting && onCancelConnect ? (
@@ -445,10 +448,25 @@ export const ProviderCard: React.FC<ProviderCardProps> = ({
message={t('cloudSync.provider.disconnect.confirmMessage', { name })}
confirmLabel={t('cloudSync.provider.disconnect.confirmAction')}
destructive
onOpenChange={setDisconnectConfirmOpen}
busy={disconnecting}
onOpenChange={(open) => {
if (!disconnecting) setDisconnectConfirmOpen(open);
}}
onConfirm={() => {
setDisconnectConfirmOpen(false);
onDisconnect();
setDisconnecting(true);
void Promise.resolve()
.then(() => onDisconnect())
.then(() => {
setDisconnectConfirmOpen(false);
toast.info(t('cloudSync.provider.disconnect.cloudRemains', { name }), {
title: t('cloudSync.provider.disconnect.toastTitle'),
duration: 8000,
});
})
.catch(() => {
toast.error(t('cloudSync.provider.disconnect.failed', { name }));
})
.finally(() => setDisconnecting(false));
}}
/>
</div>
@@ -558,6 +576,7 @@ const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
snippetPackages: 'cloudSync.conflict.entity.snippetPackages',
portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules',
groupConfigs: 'cloudSync.conflict.entity.groupConfigs',
aiSessions: 'cloudSync.aiSessions.title',
settings: 'cloudSync.conflict.entity.settings',
};
@@ -15,6 +15,7 @@ import type { useCloudSync } from '../../application/state/useCloudSync';
import { storePluginSyncSecretsThenConnect } from '../../application/pluginSyncConnectWithSecrets';
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
import { pluginExtensionBridge } from '../../application/state/pluginExtensionBridge';
import { Switch } from '../ui/switch';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
@@ -92,6 +93,8 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
onDowngradeConvergent,
disconnectOtherProviders,
}) => {
const [aiSessionsSyncBusy, setAiSessionsSyncBusy] = useState(false);
const [pluginSyncProviders, setPluginSyncProviders] = useState<Array<{
id: string;
title: string;
@@ -554,7 +557,9 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
? t('cloudSync.strategy.preferCloud')
: sync.syncStrategy === 'preferLocal'
? t('cloudSync.strategy.preferLocal')
: t('cloudSync.strategy.smartMerge')}
: sync.syncStrategy === 'smartMergePreserve'
? t('cloudSync.strategy.smartMergePreserve')
: t('cloudSync.strategy.smartMerge')}
</SelectTrigger>
<SelectContent className="max-w-[min(520px,var(--radix-select-trigger-width))]">
<SelectItem value="smartMerge" className="items-start py-2">
@@ -565,6 +570,14 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
</div>
</div>
</SelectItem>
<SelectItem value="smartMergePreserve" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.smartMergePreserve')}</div>
<div className="text-xs text-muted-foreground leading-snug">
{t('cloudSync.strategy.smartMergePreserveDesc')}
</div>
</div>
</SelectItem>
<SelectItem value="preferCloud" className="items-start py-2">
<div className="space-y-0.5">
<div>{t('cloudSync.strategy.preferCloud')}</div>
@@ -585,6 +598,29 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
</Select>
</SettingsAnchor>
<SettingsAnchor anchorId="sync-ai-sessions" className="p-4 rounded-lg border bg-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">{t('cloudSync.aiSessions.title')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.aiSessions.desc')}
{' '}{t('cloudSync.aiSessions.compatibility')}
</div>
</div>
<Switch
aria-label={t('cloudSync.aiSessions.title')}
checked={sync.aiSessionsSyncEnabled}
disabled={aiSessionsSyncBusy}
onCheckedChange={(enabled) => {
setAiSessionsSyncBusy(true);
void sync.setAiSessionsSyncEnabled(enabled).catch(() => {
toast.error(t('cloudSync.aiSessions.enableFailed'));
}).finally(() => setAiSessionsSyncBusy(false));
}}
/>
</div>
</SettingsAnchor>
{sync.hasAnyConnectedProvider && (
<div className="space-y-3">
{/* Version Info Cards */}
+1 -1
View File
@@ -158,7 +158,7 @@ export interface NetcattyAiBridge {
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; warning?: string; source?: 'live' | 'last-known-good'; fetchedAt?: string; error?: string }>;
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
@@ -14,7 +14,7 @@ import {
queryRecentHistoryByCommand,
fuzzyQueryHistory,
type HistoryQueryOptions,
} from "./commandHistoryStore";
} from "../../../application/state/commandHistoryStore";
import {
loadSpec,
hasSpec,
+1 -1
View File
@@ -2,5 +2,5 @@ export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTer
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "../../../application/state/commandHistoryStore";
export { shellEscape } from "./completionEngine";
@@ -54,7 +54,7 @@ Object.defineProperty(globalThis, "window", {
configurable: true,
});
const { clearHistory, queryHistory } = await import("./commandHistoryStore.ts");
const { clearHistory, queryHistory } = await import("../../../application/state/commandHistoryStore.ts");
const { seedLocalShellHistoryFromHistfiles } = await import("./localShellHistorySeed.ts");
const { getCompletions } = await import("./completionEngine.ts");
@@ -20,7 +20,12 @@ import {
parseZshHistory,
} from "../../../domain/remoteHistory";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { flushCommandHistoryStore, recordCommand } from "./commandHistoryStore";
import {
flushCommandHistoryStore,
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
recordCommand,
} from "../../../application/state/commandHistoryStore";
const SEED_FLAG_PREFIX = "netcatty:localHistSeeded:";
const MAX_SEED_COMMANDS = 500;
@@ -93,6 +98,18 @@ async function seedLocalShellHistoryFromHistfilesOnce(
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
// A clear is a user decision to delete locally recorded history. Never
// re-import histfiles for a host that was not seeded before that clear, or
// the deleted commands would reappear on the next Local Terminal open.
if (hasCommandHistoryBeenCleared()) {
markSeeded(hostId);
return 0;
}
// Capture the clear generation before the bridge reads so a clear that lands
// while they are in flight can be detected afterwards.
const startClearGeneration = getCommandHistoryClearGeneration();
const bridge = getBridge();
if (!bridge?.getHomeDir || !bridge.readLocalFile) return 0;
@@ -111,6 +128,17 @@ async function seedLocalShellHistoryFromHistfilesOnce(
readTextFile(bridge, joinHomePath(home, ".config/fish/fish_history")),
]);
// The user cleared history while the histfile reads were in flight. Discard
// the import and mark this host seeded so a later Local Terminal open cannot
// resurrect the commands that were just deleted.
if (
hasCommandHistoryBeenCleared()
|| getCommandHistoryClearGeneration() !== startClearGeneration
) {
markSeeded(hostId);
return 0;
}
const lists = [
zshText ? parseZshHistory(zshText) : [],
bashText ? parseBashHistory(bashText) : [],
@@ -3,7 +3,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { GhostTextAddon } from "./GhostTextAddon";
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
import { getAlignedPrompt } from "./promptDetector";
import { recordCommand } from "./commandHistoryStore";
import { recordCommand } from "../../../application/state/commandHistoryStore";
import { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
interface TerminalAutocompleteInputContext {
@@ -17,7 +17,7 @@ import {
} from "./promptDetector";
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import type { Snippet } from "../../../domain/models";
import { recordCommand } from "./commandHistoryStore";
import { recordCommand, subscribeCommandHistoryCleared } from "../../../application/state/commandHistoryStore";
import { seedLocalShellHistoryFromHistfiles } from "./localShellHistorySeed";
import { shellEscape } from "./completionEngine";
import { preloadCommonSpecs } from "./figSpecLoader";
@@ -1313,6 +1313,32 @@ export function useTerminalAutocomplete(
});
}, [termRef, writeToTerminal, repositionPopup]);
/**
* Drop every trace of the popup when command history is cleared, including a
* clear performed in another renderer (e.g. the main window while this
* detached popup is open).
*
* Mirrors the Escape path: live preview writes the highlighted candidate into
* the shell line, so hiding the popup alone would leave the deleted command
* ready for Enter, with `lastAcceptedCommandRef` still pointing at it.
* Restore the typed baseline and reset the refs first.
*/
const handleCommandHistoryCleared = useCallback(() => {
if (previewActiveRef.current) {
renderPreviewSelection(-1); // restore the typed baseline
}
clearState();
previewActiveRef.current = false;
// Drop a command accepted before the clear so a fast Enter cannot record it
// through the accepted-command path (the line buffer is authoritative now).
lastAcceptedCommandRef.current = null;
}, [clearState, renderPreviewSelection]);
useEffect(
() => subscribeCommandHistoryCleared(handleCommandHistoryCleared),
[handleCommandHistoryCleared],
);
/** Accept a snippet: clear the user's typed input, then run it via the
* host-canonical send path (onAcceptSnippet). */
const acceptSnippet = useCallback((snippet: Snippet): boolean => {
+1 -1
View File
@@ -114,7 +114,7 @@ const {
clearHistory,
recordCommand,
removeCommandHistoryEntry,
} = await import("./autocomplete/commandHistoryStore.ts");
} = await import("../../application/state/commandHistoryStore.ts");
const {
normalizePathTokenForLookup,
shouldPreferRemoteShellCwd,
@@ -8,9 +8,6 @@ import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
@@ -317,7 +314,6 @@ export const resetKittyKeyboardModeStateForSession = (
export type CreateXTermRuntimeContext = {
container: HTMLDivElement;
host: Host;
getHost?: () => Host;
fontFamilyId: string;
resolvedFontFamily: string;
fontSize: number;
@@ -958,13 +954,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
terminalLinkHandler.activate(event, uri);
});
term.loadAddon(webLinksAddon);
const archiveLinks = registerTerminalArchiveLinks({
term,
isEnabled: () => ctx.statusRef.current === 'connected'
&& !ctx.isBroadcastEnabledRef.current
&& supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId),
});
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
? registerPluginTerminalLinkProvider({
term,
@@ -3489,7 +3478,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
keywordHighlighter.dispose();
cursorLineHighlighter.dispose();
pluginLinkProviderHost?.dispose();
archiveLinks.dispose();
pluginProviderHost?.dispose();
eraseScrollbackDisposable.dispose();
for (const disposable of cursorPositionReportRequestDisposables) {
@@ -1,67 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { ILink, ILinkProvider, Terminal } from '@xterm/xterm';
import { registerTerminalArchiveLinks } from './terminalArchiveLinks';
function fixture() {
let provider!: ILinkProvider;
let text = 'backup.tar.gz';
let enabled = true, ready = true, registrationDisposed = false, wrapped = false;
const pasted: string[] = [];
let submitted: (data: string) => void = () => {};
let markerLine = -1;
const term = {
buffer: { active: { getLine: (y: number) => y === 0 ? { translateToString: () => text, isWrapped: wrapped } : undefined } },
paste: (value: string) => { pasted.push(value); ready = false; },
focus: () => {},
registerMarker: () => ({ line: markerLine, isDisposed: false, dispose() { this.isDisposed = true; } }),
onData: (listener: (data: string) => void) => { submitted = listener; return { dispose() {} }; },
registerLinkProvider: (next: ILinkProvider) => { provider = next; return { dispose: () => { registrationDisposed = true; } }; },
};
const registration = registerTerminalArchiveLinks({ term: term as unknown as Terminal, isEnabled: () => enabled, isInputReady: () => ready });
const links = () => { let value: ILink[] | undefined; provider.provideLinks(1, result => { value = result; }); return value; };
return { links, pasted, registration, submit: () => { markerLine = 0; submitted('\r'); }, setText: (value: string) => { text = value; }, setReady: (value: boolean) => { ready = value; }, setEnabled: (value: boolean) => { enabled = value; }, setWrapped: () => { wrapped = true; }, isDisposed: () => registrationDisposed };
}
const ctrlClick = { button: 0, ctrlKey: true } as MouseEvent;
test('Ctrl+left click prepares exactly one command without Enter; ordinary click does nothing', () => {
const f = fixture(), link = f.links()![0];
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 13, y: 1 } });
link.activate({ button: 0, ctrlKey: false } as MouseEvent, link.text);
assert.deepEqual(f.pasted, []);
link.activate(ctrlClick, link.text);
link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, ["tar -xzf './backup.tar.gz'"]);
});
test('live readiness/platform checks preserve a draft even for cached links', () => {
const f = fixture(), link = f.links()![0];
f.setReady(false);
assert.equal(f.links(), undefined);
link.activate(ctrlClick, link.text);
f.setReady(true); f.setEnabled(false);
link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, []);
});
test('stale output and disposed links cannot prepare commands; wrapped fragments are excluded', () => {
const f = fixture(), link = f.links()![0];
f.setText('changed.tar.gz'); link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, []);
f.setWrapped(); assert.equal(f.links(), undefined);
f.registration.dispose(); assert.equal(f.isDisposed(), true);
link.activate(ctrlClick, link.text); assert.deepEqual(f.pasted, []);
});
test('submitting another command invalidates old relative names and cached links, but not absolute paths', () => {
const f = fixture(), link = f.links()![0];
f.submit();
assert.deepEqual(f.links(), []);
link.activate(ctrlClick, link.text);
assert.deepEqual(f.pasted, []);
f.setText('/tmp/backup.tar.gz');
const absolute = f.links()![0];
absolute.activate(ctrlClick, absolute.text);
assert.deepEqual(f.pasted, ["tar -xzf '/tmp/backup.tar.gz'"]);
});
@@ -1,61 +0,0 @@
import type { IDisposable, ILink, Terminal } from '@xterm/xterm';
import { findTerminalArchives } from '../../../domain/terminalArchives';
import { pluginTerminalCellRange, readPluginTerminalBufferText } from '../pluginTerminalBufferText';
/** Archive commands are prepared only at an empty shell prompt, never submitted. */
export function registerTerminalArchiveLinks(options: {
term: Terminal;
isEnabled: () => boolean;
isInputReady: () => boolean;
}): IDisposable {
let disposed = false;
const { term } = options;
// Relative names belong only to output from the latest submitted command.
// A marker follows scrollback trimming without keeping a cwd history.
let commandStart = term.registerMarker(0);
const submission = term.onData(data => {
if (!/[\r\n]/u.test(data)) return;
commandStart?.dispose();
commandStart = term.registerMarker(0);
});
const hasRelativeContext = (y: number) => commandStart !== undefined
&& !commandStart.isDisposed && y - 1 > commandStart.line;
const registration = term.registerLinkProvider({
provideLinks(y, callback) {
if (disposed || !options.isEnabled() || !options.isInputReady()) {
callback(undefined);
return;
}
const line = term.buffer.active.getLine(y - 1);
// Do not mistake a fragment of a wrapped filename for a complete archive.
if (!line || line.isWrapped || term.buffer.active.getLine(y)?.isWrapped) {
callback(undefined);
return;
}
const printed = readPluginTerminalBufferText(line, true);
if (printed.text.length > 8192) {
callback(undefined);
return;
}
const links: ILink[] = findTerminalArchives(printed.text).flatMap((match) => {
if (match.relative && !hasRelativeContext(y)) return [];
const cells = pluginTerminalCellRange(printed, match.start, match.length);
if (!cells) return [];
return [{
range: { start: { x: cells.x + 1, y }, end: { x: cells.x + cells.width, y } },
text: match.text,
decorations: { pointerCursor: true, underline: true },
activate(event) {
if (disposed || event.button !== 0 || !event.ctrlKey || !options.isEnabled() || !options.isInputReady()) return;
if (match.relative && !hasRelativeContext(y)) return;
if (term.buffer.active.getLine(y - 1)?.translateToString(true) !== printed.text) return;
term.paste(match.command);
term.focus();
},
}];
});
callback(links);
},
});
return { dispose() { disposed = true; submission.dispose(); commandStart?.dispose(); registration.dispose(); } };
}
@@ -81,3 +81,21 @@ test("single-channel / network-device sessions skip remote path listings", () =>
assert.match(source, /skipPathCompletion: isNetworkDeviceRef\.current,/);
});
test("clearing command history restores the live-preview baseline (#3632)", () => {
const source = readFileSync(
fileURLToPath(new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url)),
"utf8",
);
// A live-previewed candidate is written into the shell line, so the clear
// handler must mirror the Escape path: restore the typed baseline, reset the
// preview/accepted refs, and only then hide the popup.
assert.match(
source,
/const handleCommandHistoryCleared = useCallback\(\(\) => \{\s*if \(previewActiveRef\.current\) \{\s*renderPreviewSelection\(-1\);[\s\S]*?clearState\(\);\s*previewActiveRef\.current = false;[\s\S]*?lastAcceptedCommandRef\.current = null;\s*\}, \[clearState, renderPreviewSelection\]\);/,
);
assert.match(
source,
/useEffect\(\s*\(\) => subscribeCommandHistoryCleared\(handleCommandHistoryCleared\),\s*\[handleCommandHistoryCleared\],\s*\);/,
);
});
@@ -215,8 +215,6 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
effectiveTerminalProtocol,
@@ -519,7 +517,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const runtime = createXTermRuntime({
container: containerRef.current,
host,
getHost: () => archiveHostRef.current,
fontFamilyId,
resolvedFontFamily,
fontSize,
@@ -695,6 +695,7 @@ export interface TerminalLayerProps {
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onDeleteShellHistoryEntry?: (entryId: string) => void;
onClearCommandHistory?: () => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
@@ -461,6 +461,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
followAppTerminalTheme: s.followAppTerminalTheme,
handleHistoryPaste: s.handleHistoryPaste,
handleHistoryDelete: s.handleHistoryDelete,
handleHistoryClear: s.handleHistoryClear,
handleHistoryRun: s.handleHistoryRun,
handleFocusSidePanelPane: s.handleFocusSidePanelPane,
handleMagnifySidePanelPane: s.handleMagnifySidePanelPane,
@@ -493,6 +493,7 @@ function SidePanelHistorySlotInner({
HistorySidePanel,
handleHistoryPaste,
handleHistoryDelete,
handleHistoryClear,
handleHistoryRun,
} = ctx;
@@ -507,6 +508,7 @@ function SidePanelHistorySlotInner({
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
onFetch={remoteHistory.fetch}
onDeleteGlobalEntry={handleHistoryDelete}
onClearGlobalHistory={handleHistoryClear}
onPasteToTerminal={handleHistoryPaste}
onRunInTerminal={handleHistoryRun}
isVisible
@@ -234,6 +234,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'themeMountedTabIds',
'handleHistoryPaste',
'handleHistoryDelete',
'handleHistoryClear',
'handleHistoryRun',
'handleOpenHistory',
'HistorySidePanel',
+1
View File
@@ -66,6 +66,7 @@ export const terminalLayerAreEqual = (
prev.onUpdateHost === next.onUpdateHost &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.onDeleteShellHistoryEntry === next.onDeleteShellHistoryEntry &&
prev.onClearCommandHistory === next.onClearCommandHistory &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more