Compare commits

..
Author SHA1 Message Date
陈大猫 7949242765 fix: bracket multiline compose submissions when supported (#4163)
Use shell-advertised bracketed paste for compose command blocks even when clipboard bracketing is disabled. Keep the final Enter outside the paste and preserve snippet preferences and compose target routing.

Fixes #4062
2026-11-06 22:08:09 +08:00
陈大猫 a0be25d107 fix: keep AI side panel close button visible when narrowed (#4162)
Add shrink-0 to the AI side panel close button so it stays visible when the panel is narrowed near its minimum width.

Fixes #4160
2026-11-06 19:05:23 +08:00
陈大猫andCodex 3e454a84a5 fix: allow resolving convergent field conflicts in migration preview (#4148) (#4153)
Co-authored-by: Codex <codex@local.invalid>
2026-11-06 12:41:38 +08:00
陈大猫 a70e21d39b fix: reduce autocomplete history query cost (#4149) (#4152) 2026-11-06 12:09:38 +08:00
+1 16061720e6 feat(ai): add safe OSC 133 shell readiness (#4110)
* fix(terminal): clear sticky broadcast latch on vim ex (#4107) (#4109)

Fixes #4107.

Merge gate: GitHub Codex clean on tip b7efbe347; local CLEAN residual (unknown colon-prefixed alt-screen challenges) left as documented thrash; CI green (lint-and-test / windows-fido / windows-broadcast-ssh); Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4109-b7efbe347-20261104-122316 (both panes exited vim via toolbar broadcast :q!).

* perf(ai): stop freezing the app during agent discovery and chat backups (#4116)

Both paths ran synchronous I/O on the Electron main process, which freezes
every window and the terminal keystroke relay while it runs:

- Agent discovery (opening the AI panel or Settings > AI) probed auth with
  execFileSync: `security find-generic-password` (4 s timeout), `gh auth
  status` (network, 5 s), `which cursor-agent` (4 s) and `cursor-agent status`
  (8 s), one after another. The probes now use a non-rejecting async execFile
  helper with the same result shape; discovery awaits them.
- Cursor SDK detection evaluated `import("@cursor/sdk")` (~4 MB of ESM plus
  its transport stack) only to check that it is installed, and kept it
  resident afterwards. It now checks for the ESM entry that the dynamic import
  loads; the SDK itself still loads on first use. The package does not export
  package.json and its CJS build is stripped from releases, so the check walks
  require.resolve.paths() instead of using require.resolve.
- The AI chat backup wrote the whole history (often several MB) with
  writeFileSync on every debounced change. Writes now use fs.promises and are
  serialized so generations cannot interleave.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(ui): stop whole-shell re-renders on clicks, focus, typing and modifiers (#4117)

Several everyday interactions re-rendered the entire shell (AppView, TopTabs,
TerminalLayer and every pane) even though nothing they show had changed:

- Clicking or focusing a split pane always replaced the workspaces array
  (twice per click: onClick + onFocusCapture), cascading through the whole
  app and re-saving the session restore snapshot. setWorkspaceFocusedSession
  and moveFocusInWorkspace now keep the same array when focus is unchanged
  (domain/workspace.ts setWorkspaceFocusedSessionId).
- Every window focus/visibility change resynced app lock and produced a new
  runtime state object even when nothing changed, re-rendering the provider
  tree. Equal states keep the current object and useAppLockState's result is
  memoized.
- Quick switcher keystrokes went through AppSideEffects state and the
  app-local UI store, re-rendering AppSideEffects, every host island, AppShell,
  AppView and TopTabs per character. QuickSwitcherOverlay now owns the query
  and host results; the overlay unmounts on close, which resets the query
  exactly where setQuickSearch('') used to.
- Pressing/releasing Ctrl/Cmd (every ⌘C/⌘V/Ctrl+C) re-rendered the whole tab
  bar to toggle shortcut number badges. The held flag now lives in a small
  store read only by each tab's TabIconSlot.
- AppView passed fresh inline callbacks that defeated TopTabs' memo comparator
  and every open editor tab; TextEditorTabView is now memoized. TopTabs
  memoizes per-workspace session lists and hoists the root tab icons.
- Workspace drag previews only update state when the drop hint actually moves.

React commits / render time for 10 repetitions in the real app (profiling
build, 2 split panes, 300 hosts), main -> this change:
  window focus            42 / 17.1 ms -> 2 / 0.4 ms
  press/release Cmd       50 / 41.9 ms -> 22 / 6.6 ms
  click focused pane      42 / 69.6 ms -> 3 / 0.4 ms
  quick switcher typing   41 / 61.3 ms -> 16 / 34.8 ms

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(terminal): cap live WebGL contexts, release them eagerly, bound note undo history (#4122)

* perf(terminal): cap live WebGL contexts and stop the context-loss cascade

Chromium keeps at most 16 WebGL contexts per renderer and force-loses the
one that drew least recently past that. Hidden terminal panes keep their
WebGL renderer unless hibernation is on, so with more than 16 terminal
tabs every forced loss was rebuilt 50 ms later, evicting another context.
With 20 local tabs, half of all tab switches landed on a lost context
(xterm waits 3 s before falling back), and terminals that lost three
contexts within a minute were parked on the DOM renderer for good.

- Add a shared context budget (12). Past it, renderers of panes hidden for
  at least 3 s are released, least recently seen first. Visible panes are
  never released or refused; a released pane rebuilds when shown.
- A context lost while hidden waits for the reveal instead of rebuilding
  at once, and no longer counts toward the breaker.
- The short-hide CSS reveal path now also asks for the renderer (a no-op
  while it is live).
- Disposing the addon left its context alive until GC. Lose it explicitly
  so GPU memory and the Chromium slot come back right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(notes): bound the source editor's undo history

Every undo step in the markdown source editor holds a full copy of the
note, and the stack never shrank. A long session on a large note kept
every intermediate version alive (a 100 KB note with 1,000 steps holds
~200 MB of UTF-16 text).

Keep the 200 most recent steps and at most 8M characters across them,
dropping the oldest first. The newest step always survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(terminal): stop per-second churn while connecting and while idle (#4118)

* perf(terminal): stop per-second churn while connecting and while idle

- Connecting sessions: the 200 ms progress animation and 1 s countdown were
  Terminal state threaded into the TerminalView ctx, so every connecting
  session (hidden tabs and startup restores included) re-rendered Terminal +
  TerminalView up to six times a second. Terminal now only publishes the
  timeout deadline and real progress events; the connection dialog animates
  and counts down locally (connectionTicker.ts). HostConnectionTestDialog
  keeps passing progress/timeLeft as before.
- Monitor runtime: polled every second unconditionally and woke every
  subscriber each time because takenAt always changes. It now polls at 1 s
  only while something is monitored (here or in another window), 10 s
  otherwise, and skips notifying for unchanged idle replies.
- Auto-reconnect progress log: capped at the newest 300 lines (it grew by a
  few lines every 5 s retry for as long as an outage lasted).
- App Lock: report activity at most once a second instead of an IPC per
  keystroke / wheel tick / pointer event.
- Tray/Dock: the renderer resends hosts only when the host list changed, and
  the main process rebuilds the macOS Dock menu (sort + one native item per
  host) only then, not on every session status change.

Real app, profiling build, main -> this change:
  3 SSH sessions connecting (3 s)   14 commits / 35.3 ms -> 8 / 1.7 ms
  idle (10 s)                       13 commits / 3.1 ms  -> 2 / 0.1 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(terminal): update observe-popup reconnect deps assertion for capped progress logs

CI failed because #4118 wrapped setProgressLogs in useCallback to apply
capConnectionProgressLogs and added it to scheduleAutoReconnect deps.
The observe-popup source assertion still expected the old deps list.

The attachExistingSession early-return behavior is unchanged; only the
stale dependency regex needed updating.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(state): stop handler churn on session updates and repeated JSON work (#4121)

* perf(state): stop handler churn on session updates and repeated JSON work

- Shell handlers: AppSideEffects registers ~58 glue callbacks for the Host
  islands. Several close over sessions/workspaces/hosts (split/copy/duplicate,
  close tabs, delete host, keyboard-interactive, connect...), so every session
  status change and host update (connect bursts, last-connected times)
  re-notified the bridge and rebuilt all four shell bags, re-rendering AppView
  and TopTabs. They are now registered behind per-key stable wrappers that
  call the latest implementation (stabilizeAppHandlers); local callbacks and
  their effects are unchanged. All registered entries are actions, setters or
  refs, never read during render.
- Session actions: moveFocusInWorkspace, copySession and copyWorkspace read
  workspaces / orphan sessions / log views through refs at call time (the same
  snapshot the closure captured), so they keep a stable identity. Getters read
  during render (isBroadcastEnabled, getOrderedWorkTabs, ...) are untouched.
- AI history: the live store was parsed five times when the AI state root
  mounted; parseSessionsStore parses once per call (two parses total).
- AI history writes: the synced branch serializes once and reuses the string
  for the disk mirror, and rewriting the same immutable sessions array (save
  path, then cloud-sync collection) is skipped while storage still holds that
  exact write in the same mode.
- Command history: saving after each Enter parsed the whole store (up to 10k
  entries) only to read its clear generation, and every other window parsed it
  again on the storage event. The generation is now serialized first and read
  from the raw string (full parse only to adopt a peer clear), and the
  debounced write runs when the renderer is idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after cancelled lint-and-test run

Previous lint-and-test was cancelled mid-run.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>

* chore: fix outstanding ESLint warnings (#4112)

* chore: fix outstanding ESLint warnings

- useSftpFollowTerminalCwd: drop activeSessionId/focusedSessionId from the
  syncFollowToTerminalCwd deps; the callback reads them through refs and the
  only consumer effect already re-runs on those ids.
- useSftpPaneDialogs: list the stable setShowDeleteConfirm callback in the
  handleDelete/openDeleteConfirm deps.
- csvProxy: remove the unnecessary `\[` escapes inside character classes
  (same matches under the `u` flag).
- Remove unused test imports/variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after runner SIGTERM cancel

Previous lint-and-test was cancelled by runner shutdown, not a real failure.

* chore: retrigger CI after second runner cancel

Lint had already passed; plugin-contract step was cancelled by the runner.

* chore: retrigger CI after third runner cancel

Lint and schema steps passed; plugin-contract step was cancelled by
the runner again. Empty commit to re-queue lint-and-test.

* chore: retrigger CI after repeated runner cancel

plugin-contract step keeps being cancelled by the Actions runner
(not a test failure). Empty commit to re-queue; Actions rerun API
denied for this PAT.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>

* perf: add opt-in startup timeline and real-app startup harness (#4114)

* perf: add opt-in startup timeline and real-app startup harness

- electron/perfTrace.cjs records first-occurrence startup marks in the main
  process when NETCATTY_PERF_TRACE=1 (a no-op otherwise), then writes the
  main-window renderer timeline (navigation, paint, netcatty:* marks, JS heap)
  and app.getAppMetrics() to JSON after the first renderer-ready signal.
- Marks: main.cjs entry / modules loaded / whenReady, main window creation,
  registerBridges and plugin-host start/end, loadURL, did-finish-load,
  renderer ready, window shown. The renderer adds three performance.mark()
  calls (entry evaluated, App mounted, renderer ready), also visible in
  DevTools.
- scripts/perf-startup.live.cjs (npm run perf:startup) launches the built app
  N times against a throwaway --user-data-dir profile with ssh:// deep-link
  registration disabled, optionally seeds synthetic hosts over CDP, and
  reports cold plus warm p50/p90.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(harness): add --profile option to reuse a startup profile

Lets the harness keep one profile across invocations, e.g. to compare warm
V8 code caches across builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after flaky monitorRuntimeIsolation failure

Previous lint-and-test failed on known-preexisting
`useEffect is not defined` in monitorRuntimeIsolation.test.ts.

* chore: retrigger CI after monitorRuntimeIsolation useEffect flake

Known pre-existing flake: monitor controls test fails with
`useEffect is not defined`. Empty commit to re-run once.

* test(app): bind React hooks in monitorRuntimeIsolation VM sandbox

Extracting component declarations drops their React imports, leaving bare hooks such as useEffect undefined in the VM. Inject common React hooks and APIs while preserving explicit binding overrides to prevent intermittent monitor isolation failures in CI.

Stub the startup performance mark through App test bindings so the mount effect can execute without browser globals. Production components remain unchanged.

Validation: monitorRuntimeIsolation passed five consecutive runs. Plain npm run lint found 17 existing errors in git-ignored scratch files; excluding only _t.mjs and sim-4107.mjs passed with zero errors and eight existing warnings.

* chore: retrigger CI after plugin-contract runner cancel

Lint/schema passed; plugin-contract step cancelled by the runner
(not a test failure). Empty commit to re-queue.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>

* perf(main): stop blocking the first window on validator compilation (#4115)

The main process ran ~1.4 s of synchronous work in registerBridges before the
main window could start loading (app:// assets are served from the main
thread too), even for users without plugins:

- contractValidator: every createDefinitionValidator() built a fresh Ajv
  instance and re-registered the 200 KB plugin contract (~17 ms each, ~90 of
  them across the plugin host at startup, plus one per plugin view resource
  request). Share one Ajv per process, compile each definition on first use
  and memoize it; each validator still exposes its own `.errors`.
- distributionSnapshot: compile the three distribution schemas lazily on the
  shared contract Ajv instead of a second Ajv at module load (273 ms -> 37 ms).
- pluginBridge: hoist the PluginViewResourceRequest validator to module scope.
- cloudSyncBridge: load webdav / @aws-sdk/client-s3 / @smithy/node-http-handler
  on first sync instead of at startup (108 ms -> 4 ms).
- app:// scheme: enable codeCache so V8 reuses compiled renderer bundles
  across launches and windows. A same-length in-place edit of the cached entry
  chunk runs the new code, so stale cache is not reused after upgrades.

Startup harness (2000 hosts, 10 runs, warm p50): registerBridges 1421 ->
116 ms, plugin host 871 -> 30 ms, renderer ready 2147 -> 723 ms, total memory
923 -> 823 MB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): keep sessions past 2MB localStorage budget via overflow (#4123)

Urgent fix for #4123: retain AI chat history past the 2MiB primary localStorage budget via overflow chunks + pre-split disk backup, with disk-only recovery markers, aggregate-quota degradation, import live-state retention, and count-cap marker resolution.

Gate: local Codex CLEAN (no P1) on 084ed813; CI green on acabe0bbc; Electron smoke PASS (pr-4124-acabe0bbc). Residual P2s noted (cross-window reconcile; vault-restore marker clear; retention-cap during pending recovery).

No release.

* perf(renderer): cut the startup bundle by a third (#4120)

* perf(renderer): cut the startup bundle by a third

Every window parsed ~6.1 MB of JavaScript before the first render. The entry
chunk carried all five UI locales, and the whole AI SDK chunk was preloaded
even though the AI panel is lazy.

- i18n: only English stays in the entry; other dictionaries load on demand
  (plus their same-base fallback, e.g. zh-TW -> zh-CN). index.tsx awaits the
  saved language before the first render, and I18nProvider re-renders when a
  runtime switch finishes loading.
- AI runtime: aiStateSnapshots cleaned up chats through getAgentRuntime(),
  and the plugin AI/agent hosts created their runtimes on mount, which pulled
  the Catty harness and @ai-sdk providers into startup. Cleanup now goes
  through a light handle that the runtime registers (the reversible privacy
  flag and main-process privacy mapping are still cleared without a runtime),
  and both plugin hosts create their runtime on the first request, re-checking
  liveness after the async load.
- Chunking: manualChunks' object form also moved Vite's dynamic-import
  preload helper into vendor-ai (the AI SDK itself uses import()), so the
  entry imported it from there and vendor-ai (649 KB) was preloaded on every
  launch. Vendor chunks now use the function form and the helper gets its own
  1 KB chunk.
- jsqr is loaded when a QR image is imported instead of with the host editor.
- startupBundle.architecture.test.ts guards these invariants.

Startup JS (index.html): 5.29 MB entry + 649 KB vendor-ai + 138 KB radix ->
3.80 MB entry + 1 KB helper + 174 KB radix (-35%). Startup harness, 2000 hosts,
10 runs, warm p50: loadURL -> renderer entry evaluated 321 -> 207 ms,
loadURL -> renderer ready 481 -> 323 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(i18n): preload locale messages before static rendering

* test(i18n): preload locales in remaining zh-CN UI tests

Non-English dictionaries now load lazily, so I18nProvider can initially render English before its effect finishes loading zh-CN. Explicitly await loadLocaleMessages before rendering these localized UI tests to fix the remaining CI failures without timing-based waits or production changes.

* chore: retrigger CI after locale preload fix

Previous tip 38108ce66 was on the branch but the PR head stayed
stuck on d49ad024e after a GitHub 500 during push. Empty commit
to refresh the PR head and re-queue checks.

* chore: retrigger CI after plugin-contract runner cancel

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(terminal): cache ligature font parses across terminals (#4126)

* fix(pty): forward childProcesses queries to terminal worker (#4127)

Worker-owned PTY sessions register their PIDs in the worker, leaving the main process map empty during close-busy checks.

Forward child-process queries to the terminal worker and register a worker handler for string and object payloads. Fall back to the main process tree if the worker is unavailable or returns a non-array result.

* fix(ui): keep follow-system themeSource from chrome override (#4131)

When Interface Theme is Follow System, chrome refresh called setTheme(light|dark) and locked nativeTheme.themeSource, so OS appearance flips no longer updated the UI. Keep setTheme('system') on system chrome preference.

Fixes #4130

Gate: GH Codex CLEAN on 2c0c1c77fd (6635017901); CI 41860537287 success; Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4131-37c0f491a (same tree as prior tip).

* fix(transfer): keep SSH alive when canceling mid-transfer (#4134) (#4135)

* fix(transfer): keep SSH alive when canceling mid-transfer (#4134)

Cancel of a large isolated SFTP upload/download used to call sftp.end()
mid-WRITE on a channel that shares the terminal SSH transport. That
CHANNEL_CLOSE with in-flight traffic can make the remote drop the whole
connection, so the shell shows [session closed by remote (no exit status)].

Abort now drains like the shared browse path (no mid-flight end); dispose
only after settle. Cancel returns { cancelled: true } so the pool does not
treat channel-closed as session loss.

* fix(transfer): deferred-dispose soft-cancelled isolated SFTP (#4134)

Codex P2 on 88b4890: softDispose dropped cancelled isolated download
channels from the pool without ending them, leaking remote subsystems.
Schedule sftp.end() after a ~2s quiet settle instead of abandoning forever
or ending mid-flight on the shared SSH transport.

Aligned cheap fix for isolated fastPut cancel: after force-settle, defer
end of the disposable channel the same way. Stopping ssh2 fastXfer
internal scheduling still needs invasive hooks — left as documented
residual; deferred end after quiet is the minimal leak fix.

Tests cover mid-flight isolated upload/download cancel (no end while
held) and eventual deferred end after settle.

* Revert "fix(transfer): deferred-dispose soft-cancelled isolated SFTP (#4134)"

This reverts commit 5b85ee70c0.

* fix(transfer): dispose soft-cancelled SFTP only after request drain (#4134)

Codex 4475686359 on 5b85ee70: fixed 2s deferred sftp.end() still fired while
READs were pending and could recreate the shared-SSH drop. Poll sftp._requests
for quiescence before ending; if still pending past the hard max, skip end
(abandon until session close) rather than ending mid-flight.

Tests: held isolated download READs past 2s must not end until released;
fastPut gate cancel still deferred-ends after drain.

* Revert "fix(transfer): dispose soft-cancelled SFTP only after request drain (#4134)"

This reverts commit 7e9934cd7e.

* chore: retrigger CI after lint-and-test runner cancel (#4135)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>

* fix(terminal-worker): stop EPIPE ignore path from log-feedback looping (#4137)

Fixes #4136.

When the terminal utilityProcess console pipe is already dead, classifyProcessError marks EPIPE / ERR_STREAM_DESTROYED as ignore, but installTerminalWorkerErrorGuards still called logError → console.error. That write failed with EPIPE, re-entered uncaughtException, and busy-looped.

- On action === ignore: only report, never logError.
- Latch consoleBroken on stdout/stderr error so the default console.error path no-ops after the transport dies.
- Harden compressed extract cancel rollback under SIGTERM (ignore HUP/INT/TERM during cleanup, unlink archive first) and stabilize the FIFO cancel test that was flaking CI.

Out of scope: #4128 main-process EIO twin.

* fix(terminal): stop Shift+Insert CSI leaking to broadcast peers (#4142)

Fixes #4142

Squash-merge gate:
- GH Codex CLEAN on tree-identical tip c59ea270e (receipt 6653109730)
- CI SUCCESS https://github.com/binaricat/Netcatty/actions/runs/41995749622
- Electron smoke PASS /workspace/netcatty-smoke-shots/pr-4143-c59ea270e (marker both panes; Insert CSI absent)

* fix(main): stop the process error guards from logging into a dead stdio sink (#4140)

* fix(main): stop the process error guards from logging into a dead stdio sink

#4128 reports the main process freezing with an `EIO` write error raised from
`logError` -> `console.error`. That is the same feedback loop as #4136: the
guard writes diagnostics through the sink that just failed, the failure comes
back as another process-level error, and the handler logs again.

The `ignore` short-circuit that fixed the terminal worker does not cover this
one, because `EIO` is not a benign stream teardown: it is classified as
`suppress`, which logs.

- Add `createDiagnosticLogger()`. It holds an `error` listener on the
  diagnostics streams so a failed stdio write is no longer re-raised as an
  uncaught exception, and it latches closed after an async sink error or a
  synchronous throw, so later diagnostics are dropped instead of re-entering.
- Use it for the main-process logger and warner in `main.cjs`.

Errors are still captured for the crash log through `captureError`.

* fix(error-guards): forward the first stdio failure to the crash log

createDiagnosticLogger holds an `error` listener on stdout/stderr, so it
consumes the stream failure before the process guards can see it. Once the
sink latched closed, the failure was dropped from the crash log entirely:
a closed stdout pipe produced one report before the sink guard landed and
none after.

- Add an `onError` option to `createDiagnosticLogger`. It latches the sink
  closed first, then forwards only the first stream error; a throwing
  reporter cannot escape.
- Main process: report the failure through
  `crashLogBridge.captureError("stdio", err)`.
- Terminal worker: fold the inline `consoleBroken` latch onto
  `createDiagnosticLogger` and forward the first failure through
  `report`/`reportWorkerError`.

Regression coverage spawns a subprocess whose stdout/stderr pipe is closed
by the parent and asserts exactly one report is produced.

* ci: retrigger tests after unrelated plugin SIGKILL flake

PluginLogSettings.test.tsx was SIGKILL'd and the lint-and-test job was
canceled; the failure is unrelated to the stdio guard change.

---------

Co-authored-by: shibo.li <shibo.li@d-robotics.cc>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>

* fix(ai): persist AI sessions per record in IndexedDB instead of a 2MB localStorage blob (#4125)

* fix(ai): persist AI sessions per record in IndexedDB instead of a 2MB localStorage blob

Closes #4123.

The legacy store serialized every session into one localStorage JSON key
under a 2MB byte budget. Each write re-serialized the whole store, and the
degradation ladder dropped whole oldest sessions when the budget was
exceeded — silently destroying capped chats (~1MB each) on every write.

- add aiSessionIdbStorage: db netcatty_ai_sessions, one record per session
  (keyPath id) plus a meta store (schemaVersion, lastWriteAt, mirrored
  active-session map); BroadcastChannel replaces the localStorage storage
  event for cross-window sync
- one-time boot migration from netcatty_ai_sessions_v1; the legacy key
  stays untouched for one release as a read-only fallback
- writeSessionsForStorage now diffs against the last persisted set and
  writes only changed records through a serialized queue; the 2MB budget
  and its degradation ladder are removed (the global re-serialization cost
  they defended no longer exists)
- retention becomes a pure session-count cap (raised 50 -> 1000); the
  per-session 200-message cap is unchanged
- when the cap evicts sessions, a pre-eviction backup generation is
  written before the records are dropped, closing the #4018 mirror blind
  spot; explicit deletions and cloud-authoritative removals do not
  trigger it
- boot restore chain: IndexedDB -> legacy localStorage (migration) ->
  #4018 disk backup generations; a corrupt/unavailable IndexedDB falls
  back to legacy localStorage writes
- tests: IDB roundtrip/migration/active-map mirror via a minimal fake
  indexedDB, per-record put/delete diffing, count-cap eviction with
  pre-eviction generation, explicit-delete suppression, synced-history
  no-prune, corrupt-IDB fallback; byte-ladder tests removed with the
  ladder

* test(ai): update fork/undo reload tests to the IndexedDB persistence contract

Issue #4123: session persistence moved to IndexedDB per-record writes; the
persisted set no longer lands in the legacy localStorage key (read-only
fallback for one release) and the retention cap is a session count of 1000
instead of 50.

- fork/undo reload tests read the persisted set via
  getPersistedAiSessionsSnapshot() instead of parsing localStorage
- cap-boundary assertions (49/50 newer chats) become preservation
  assertions (newerCount + 2 records survive; the 1000-cap boundary is
  covered by aiStateSnapshots.test.ts)
- the reload step seeds the read-only legacy store with the persisted set,
  the stand-in for what the migration persists on a profile without
  IndexedDB

* feat(ai): make the per-session message cap user-configurable

* fix(ai): sequence session sync after boot hydration

Cloud-sync collection (collectAiSessionsForSync) could run before the
IndexedDB boot hydration settled and writeSessionsForStorage() the stale
legacy localStorage list over newer per-record IndexedDB data. The
collection now skips the sync round until the boot load settles; the next
auto-sync picks up the hydrated store.

The hydration signal lives in aiStateSnapshots (isAiSessionsHydrationComplete
/ markAiSessionsHydrationComplete) and is shared with useAIState's boot
effect and the cross-window refresh handler.

* fix(ai): make the legacy session migration one-shot via a persisted marker

The migration keyed on 'IndexedDB empty + legacy populated'. Because the
legacy key is intentionally kept for one release as a read-only fallback,
any boot whose store was empty re-ran the migration and resurrected chats
the user had deleted (P1②).

A migrationCompleted marker is now persisted in the meta store after the
migration data is durable. Once present, the legacy key is never consulted
again: an empty store is authoritative. The marker also records a store
that was already populated by a pre-marker build, and an uninitialized
empty store, so 'intentionally empty' is distinguishable from 'uninitialized'.

* fix(ai): harden boot persistence failure paths

Four review findings around the boot load's failure modes:

- P2①: when IndexedDB fails at boot and sessions come from the legacy
  store, the diff baseline was seeded even though nothing was persisted,
  so a later edit diff-wrote only the edited record and the rest were
  lost after recovery. The legacy set is now persisted best-effort (one
  re-open retry) before seeding; if that fails the baseline stays unset
  so the next edit rewrites every record.
- P2②: the active-pointer validation effect ran against the
  initially-empty in-memory session array and nulled every pointer
  before the async load finished. It now waits for the hydration signal.
- P2③: when IndexedDB stayed unopenable and the legacy store was
  missing/corrupt, the unconditional recheck threw into the swallowing
  outer catch and the #4018 disk backup was never applied. The recheck
  failure is now contained (one retry already happens inside open()),
  the newest backup generation is applied as the recovery source, and
  the error is surfaced via console.warn.
- P2④: a cross-window empty getAll (another window deleted the last
  chat) hit an early return, kept the deleted session alive locally and
  resurrected it on the next edit. After hydration an empty store is
  adopted; the early-return only covers the pre-hydration window.

Scenario regression tests live in useAIState.bootHydration.test.ts;
fakeIndexedDb gains failOpenTimes for counted open failures.

* fix(ai): gate session maintenance and migrate legacy overflow history

* fix(ai): skip backup restore of empty IDB and resume failed overflow migration

* ci: retrigger lint-and-test after runner shutdown

The previous Actions runner received a shutdown signal during plugin-contract
verification; lint had already passed and unit tests never started.

---------

Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>

* fix(ai): keep surrogate pairs whole when truncating model context (#4088)

* fix(ai): keep surrogate pairs whole when truncating model context

Head/tail truncation sliced strings by UTF-16 code unit, so a cut could
split an emoji into a lone surrogate. JSON.stringify then emits a
`\udXXX` escape that strict providers reject ("lone leading surrogate in
hex escape"), and because the same cut is reapplied every step, the chat
stays stuck on that 400 for every later request.

- Add textBoundaries helpers (sliceHead, sliceTail, safeSliceBounds) and
  use them at every truncation site on the model request path.
- Wrap provider models with a middleware that replaces any remaining lone
  surrogate with U+FFFD before the request body is serialized.

* fix(ai): keep own __proto__ keys when normalizing prompt values

toWellFormedValue cloned changed objects by plain assignment, so an own
__proto__ key (e.g. from JSON.parse) hit the inherited setter: the field
was dropped from the request and the clone's prototype changed. Clone
with the source prototype and define own properties instead.

* fix(theme): continue Windows system-theme fix with registrar coverage (#4108)

* fix(theme): broadcast native system theme updates to renderers

nativeTheme.updated only fires for listeners in the main process, so a renderer sitting in system mode never learned that Windows changed the application theme. Forward the resolved preference to every window and let the settings hook re-apply it.

* fix(theme): keep terminal chrome separate from native system policy

refreshActiveChromeThemeSurfaces called setTheme(light|dark), which the main process writes into nativeTheme.themeSource. The stored preference stayed 'system' while Electron was pinned to a fixed color, so Windows application theme changes were ignored.

Only paint the surfaces here. Also re-assert a fixed independent terminal theme when the OS preference changes: the fingerprint short-circuit skipped the refresh, and the hook effect never re-ran while the terminal props stayed stable.

* test(theme): bound the new jsdom chrome tests

A hanging jsdom/React render should fail the test with its name instead of consuming the CI job budget.

* test(theme): provide native theme in bridge registrar fixtures

* test(theme): provide nativeTheme in child-process registrar fixture

The worker tests introduced in #4127 omitted nativeTheme after the system-theme listener registration became part of the production registrar. Provide a real EventEmitter, matching the transfer-limit fixture, so registrar setup succeeds without weakening production behavior.

* ci: retrigger lint-and-test after runner shutdown

The previous ubuntu-latest lint-and-test run was canceled during plugin
contract verification (annotation: "The operation was canceled."). Lint
had already passed; tests never started. Tree is unchanged.

---------

Co-authored-by: HHXXYY123 <117068187+HHXXYY123@users.noreply.github.com>

* feat(terminal): continue Directory Picker with OSC 7 and reconnect fixes (#4113)

* feat(terminal): add Directory Picker context chip

* fix(terminal): bind directory reads to the exact SSH session

* fix(terminal): follow manually changed working directories

* fix(terminal): isolate cwd updates and gate local platform support

* docs(terminal): research Directory Picker cross-platform follow-up

* fix(terminal): confirm OSC 7 navigation and reset picker loading

* fix(terminal): preserve active shell OSC 7 during directory refresh

* test(terminal): retain cross-session OSC 7 submission guard

---------

Co-authored-by: Joseph Hau <hexart@gmail.com>

* feat(ai): support extended reasoning effort with GPT-5.5 xhigh (#4111)

* feat(ai): support xhigh and max reasoning effort levels

The provider-level reasoning default only offered low/medium/high, so models
that document the full graded ladder could not be configured to their top
tiers. DeepSeek V4/V4.1 grade `reasoning_effort` continuously up to `max`, and
Anthropic exposes `max` on 4.6+ plus `xhigh` from 4.7 onward.

Beyond the missing select options, three separate layers silently discarded
the higher values:

- REASONING_RANK stopped at `high`, so the visibility resolver treated
  `xhigh`/`max` as stale and fell back to `low`.
- The OpenAI capability check only matched `deepseek-r1`, which left the
  DeepSeek V4 family unrecognised and hid the thinking chip entirely.
- The pinned `@ai-sdk/openai@4.0.8` validates provider options against a zod
  enum capped at `xhigh`, so `max` on Chat Completions threw
  "invalid openai provider options" before any request was made.

Add a shared PROVIDER_REASONING_EFFORT_OPTIONS list used by both the settings
select and its save guard, extend the rank ladder, and teach the per-model
derivation about DeepSeek V4 grading plus the Anthropic effort generations.
Bump `@ai-sdk/openai` to 4.0.11, the first release whose enum accepts `max`;
it keeps 4.0.8's data-URL image fix.

Models whose vocabulary stops at `high` now clamp these levels onto their own
ceiling instead of dropping the setting, so an explicit user choice is never
silently ignored and no unsupported value reaches a provider.

* fix(ai): keep extended effort for explicit custom relay aliases

A custom OpenAI-compatible provider with an explicit reasoning default is the
escape hatch for relay model IDs that match no capability pattern. That branch
still advertised only low/medium/high, so resolveVisibleCattyThinkingLevel
treated a configured `xhigh` or `max` as stale and downgraded it to `high`
before the request was built.

Advertise the full ladder there: the explicit provider default is already the
user's assertion that the alias accepts reasoning_effort, so it should also
carry the ceiling they picked. Unrecognized aliases without a default keep
advertising nothing.

* test(ai): verify max effort with the pinned OpenAI SDK

---------

Co-authored-by: Arcadia <57169358+RayYiHang@users.noreply.github.com>

* fix(ai): safely gate OSC 133 readiness across input and output

* fix(ai): recover OSC readiness after erased input padding

* fix(ai): distinguish typed spaces from erased terminal cells

* fix(ai): recognize multi-cell readline erase padding

* fix(ai): gate ambiguous blank edits and cover readline kill paths

* fix(ai): preserve mixed blanks and pending prompt redraws

* fix(terminal): count Unicode display cells for shell readiness

* fix(ai): keep ambiguous readline edits gated until a trusted lifecycle

* ci: retrigger checks after rebase onto main

Empty commit; tree identical to the rebased OSC 133 tip.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
Co-authored-by: expoli <31023767+expoli@users.noreply.github.com>
Co-authored-by: shibo.li <shibo.li@d-robotics.cc>
Co-authored-by: GOUKI9999 <gouki9999@hotmail.com>
Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com>
Co-authored-by: DeepFal <41166795+DeepFal@users.noreply.github.com>
Co-authored-by: HHXXYY123 <117068187+HHXXYY123@users.noreply.github.com>
Co-authored-by: Joseph Hau <hexart@gmail.com>
Co-authored-by: Arcadia <57169358+RayYiHang@users.noreply.github.com>
2026-11-06 00:33:03 +08:00
陈大猫andArcadia 2368fc8232 feat(ai): support extended reasoning effort with GPT-5.5 xhigh (#4111)
* feat(ai): support xhigh and max reasoning effort levels

The provider-level reasoning default only offered low/medium/high, so models
that document the full graded ladder could not be configured to their top
tiers. DeepSeek V4/V4.1 grade `reasoning_effort` continuously up to `max`, and
Anthropic exposes `max` on 4.6+ plus `xhigh` from 4.7 onward.

Beyond the missing select options, three separate layers silently discarded
the higher values:

- REASONING_RANK stopped at `high`, so the visibility resolver treated
  `xhigh`/`max` as stale and fell back to `low`.
- The OpenAI capability check only matched `deepseek-r1`, which left the
  DeepSeek V4 family unrecognised and hid the thinking chip entirely.
- The pinned `@ai-sdk/openai@4.0.8` validates provider options against a zod
  enum capped at `xhigh`, so `max` on Chat Completions threw
  "invalid openai provider options" before any request was made.

Add a shared PROVIDER_REASONING_EFFORT_OPTIONS list used by both the settings
select and its save guard, extend the rank ladder, and teach the per-model
derivation about DeepSeek V4 grading plus the Anthropic effort generations.
Bump `@ai-sdk/openai` to 4.0.11, the first release whose enum accepts `max`;
it keeps 4.0.8's data-URL image fix.

Models whose vocabulary stops at `high` now clamp these levels onto their own
ceiling instead of dropping the setting, so an explicit user choice is never
silently ignored and no unsupported value reaches a provider.

* fix(ai): keep extended effort for explicit custom relay aliases

A custom OpenAI-compatible provider with an explicit reasoning default is the
escape hatch for relay model IDs that match no capability pattern. That branch
still advertised only low/medium/high, so resolveVisibleCattyThinkingLevel
treated a configured `xhigh` or `max` as stale and downgraded it to `high`
before the request was built.

Advertise the full ladder there: the explicit provider default is already the
user's assertion that the alias accepts reasoning_effort, so it should also
carry the ceiling they picked. Unrecognized aliases without a default keep
advertising nothing.

* test(ai): verify max effort with the pinned OpenAI SDK

---------

Co-authored-by: Arcadia <57169358+RayYiHang@users.noreply.github.com>
2026-11-06 00:10:09 +08:00
陈大猫andJoseph Hau efe012e0c8 feat(terminal): continue Directory Picker with OSC 7 and reconnect fixes (#4113)
* feat(terminal): add Directory Picker context chip

* fix(terminal): bind directory reads to the exact SSH session

* fix(terminal): follow manually changed working directories

* fix(terminal): isolate cwd updates and gate local platform support

* docs(terminal): research Directory Picker cross-platform follow-up

* fix(terminal): confirm OSC 7 navigation and reset picker loading

* fix(terminal): preserve active shell OSC 7 during directory refresh

* test(terminal): retain cross-session OSC 7 submission guard

---------

Co-authored-by: Joseph Hau <hexart@gmail.com>
2026-11-05 23:44:37 +08:00
陈大猫andHHXXYY123 2e3dc687cb fix(theme): continue Windows system-theme fix with registrar coverage (#4108)
* fix(theme): broadcast native system theme updates to renderers

nativeTheme.updated only fires for listeners in the main process, so a renderer sitting in system mode never learned that Windows changed the application theme. Forward the resolved preference to every window and let the settings hook re-apply it.

* fix(theme): keep terminal chrome separate from native system policy

refreshActiveChromeThemeSurfaces called setTheme(light|dark), which the main process writes into nativeTheme.themeSource. The stored preference stayed 'system' while Electron was pinned to a fixed color, so Windows application theme changes were ignored.

Only paint the surfaces here. Also re-assert a fixed independent terminal theme when the OS preference changes: the fingerprint short-circuit skipped the refresh, and the hook effect never re-ran while the terminal props stayed stable.

* test(theme): bound the new jsdom chrome tests

A hanging jsdom/React render should fail the test with its name instead of consuming the CI job budget.

* test(theme): provide native theme in bridge registrar fixtures

* test(theme): provide nativeTheme in child-process registrar fixture

The worker tests introduced in #4127 omitted nativeTheme after the system-theme listener registration became part of the production registrar. Provide a real EventEmitter, matching the transfer-limit fixture, so registrar setup succeeds without weakening production behavior.

* ci: retrigger lint-and-test after runner shutdown

The previous ubuntu-latest lint-and-test run was canceled during plugin
contract verification (annotation: "The operation was canceled."). Lint
had already passed; tests never started. Tree is unchanged.

---------

Co-authored-by: HHXXYY123 <117068187+HHXXYY123@users.noreply.github.com>
2026-11-05 23:13:41 +08:00
DeepFal 051eda6166 fix(ai): keep surrogate pairs whole when truncating model context (#4088)
* fix(ai): keep surrogate pairs whole when truncating model context

Head/tail truncation sliced strings by UTF-16 code unit, so a cut could
split an emoji into a lone surrogate. JSON.stringify then emits a
`\udXXX` escape that strict providers reject ("lone leading surrogate in
hex escape"), and because the same cut is reapplied every step, the chat
stays stuck on that 400 for every later request.

- Add textBoundaries helpers (sliceHead, sliceTail, safeSliceBounds) and
  use them at every truncation site on the model request path.
- Wrap provider models with a middleware that replaces any remaining lone
  surrogate with U+FFFD before the request body is serialized.

* fix(ai): keep own __proto__ keys when normalizing prompt values

toWellFormedValue cloned changed objects by plain assignment, so an own
__proto__ key (e.g. from JSON.parse) hit the inherited setter: the field
was dropped from the request and the clone's prototype changed. Clone
with the source prototype and define own properties instead.
2026-11-05 22:44:28 +08:00
f5fccc0d44 fix(ai): persist AI sessions per record in IndexedDB instead of a 2MB localStorage blob (#4125)
* fix(ai): persist AI sessions per record in IndexedDB instead of a 2MB localStorage blob

Closes #4123.

The legacy store serialized every session into one localStorage JSON key
under a 2MB byte budget. Each write re-serialized the whole store, and the
degradation ladder dropped whole oldest sessions when the budget was
exceeded — silently destroying capped chats (~1MB each) on every write.

- add aiSessionIdbStorage: db netcatty_ai_sessions, one record per session
  (keyPath id) plus a meta store (schemaVersion, lastWriteAt, mirrored
  active-session map); BroadcastChannel replaces the localStorage storage
  event for cross-window sync
- one-time boot migration from netcatty_ai_sessions_v1; the legacy key
  stays untouched for one release as a read-only fallback
- writeSessionsForStorage now diffs against the last persisted set and
  writes only changed records through a serialized queue; the 2MB budget
  and its degradation ladder are removed (the global re-serialization cost
  they defended no longer exists)
- retention becomes a pure session-count cap (raised 50 -> 1000); the
  per-session 200-message cap is unchanged
- when the cap evicts sessions, a pre-eviction backup generation is
  written before the records are dropped, closing the #4018 mirror blind
  spot; explicit deletions and cloud-authoritative removals do not
  trigger it
- boot restore chain: IndexedDB -> legacy localStorage (migration) ->
  #4018 disk backup generations; a corrupt/unavailable IndexedDB falls
  back to legacy localStorage writes
- tests: IDB roundtrip/migration/active-map mirror via a minimal fake
  indexedDB, per-record put/delete diffing, count-cap eviction with
  pre-eviction generation, explicit-delete suppression, synced-history
  no-prune, corrupt-IDB fallback; byte-ladder tests removed with the
  ladder

* test(ai): update fork/undo reload tests to the IndexedDB persistence contract

Issue #4123: session persistence moved to IndexedDB per-record writes; the
persisted set no longer lands in the legacy localStorage key (read-only
fallback for one release) and the retention cap is a session count of 1000
instead of 50.

- fork/undo reload tests read the persisted set via
  getPersistedAiSessionsSnapshot() instead of parsing localStorage
- cap-boundary assertions (49/50 newer chats) become preservation
  assertions (newerCount + 2 records survive; the 1000-cap boundary is
  covered by aiStateSnapshots.test.ts)
- the reload step seeds the read-only legacy store with the persisted set,
  the stand-in for what the migration persists on a profile without
  IndexedDB

* feat(ai): make the per-session message cap user-configurable

* fix(ai): sequence session sync after boot hydration

Cloud-sync collection (collectAiSessionsForSync) could run before the
IndexedDB boot hydration settled and writeSessionsForStorage() the stale
legacy localStorage list over newer per-record IndexedDB data. The
collection now skips the sync round until the boot load settles; the next
auto-sync picks up the hydrated store.

The hydration signal lives in aiStateSnapshots (isAiSessionsHydrationComplete
/ markAiSessionsHydrationComplete) and is shared with useAIState's boot
effect and the cross-window refresh handler.

* fix(ai): make the legacy session migration one-shot via a persisted marker

The migration keyed on 'IndexedDB empty + legacy populated'. Because the
legacy key is intentionally kept for one release as a read-only fallback,
any boot whose store was empty re-ran the migration and resurrected chats
the user had deleted (P1②).

A migrationCompleted marker is now persisted in the meta store after the
migration data is durable. Once present, the legacy key is never consulted
again: an empty store is authoritative. The marker also records a store
that was already populated by a pre-marker build, and an uninitialized
empty store, so 'intentionally empty' is distinguishable from 'uninitialized'.

* fix(ai): harden boot persistence failure paths

Four review findings around the boot load's failure modes:

- P2①: when IndexedDB fails at boot and sessions come from the legacy
  store, the diff baseline was seeded even though nothing was persisted,
  so a later edit diff-wrote only the edited record and the rest were
  lost after recovery. The legacy set is now persisted best-effort (one
  re-open retry) before seeding; if that fails the baseline stays unset
  so the next edit rewrites every record.
- P2②: the active-pointer validation effect ran against the
  initially-empty in-memory session array and nulled every pointer
  before the async load finished. It now waits for the hydration signal.
- P2③: when IndexedDB stayed unopenable and the legacy store was
  missing/corrupt, the unconditional recheck threw into the swallowing
  outer catch and the #4018 disk backup was never applied. The recheck
  failure is now contained (one retry already happens inside open()),
  the newest backup generation is applied as the recovery source, and
  the error is surfaced via console.warn.
- P2④: a cross-window empty getAll (another window deleted the last
  chat) hit an early return, kept the deleted session alive locally and
  resurrected it on the next edit. After hydration an empty store is
  adopted; the early-return only covers the pre-hydration window.

Scenario regression tests live in useAIState.bootHydration.test.ts;
fakeIndexedDb gains failOpenTimes for counted open failures.

* fix(ai): gate session maintenance and migrate legacy overflow history

* fix(ai): skip backup restore of empty IDB and resume failed overflow migration

* ci: retrigger lint-and-test after runner shutdown

The previous Actions runner received a shutdown signal during plugin-contract
verification; lint had already passed and unit tests never started.

---------

Co-authored-by: GOUKI9999 <GOUKI9999@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-11-05 22:20:24 +08:00
3408accba7 fix(main): stop the process error guards from logging into a dead stdio sink (#4140)
* fix(main): stop the process error guards from logging into a dead stdio sink

#4128 reports the main process freezing with an `EIO` write error raised from
`logError` -> `console.error`. That is the same feedback loop as #4136: the
guard writes diagnostics through the sink that just failed, the failure comes
back as another process-level error, and the handler logs again.

The `ignore` short-circuit that fixed the terminal worker does not cover this
one, because `EIO` is not a benign stream teardown: it is classified as
`suppress`, which logs.

- Add `createDiagnosticLogger()`. It holds an `error` listener on the
  diagnostics streams so a failed stdio write is no longer re-raised as an
  uncaught exception, and it latches closed after an async sink error or a
  synchronous throw, so later diagnostics are dropped instead of re-entering.
- Use it for the main-process logger and warner in `main.cjs`.

Errors are still captured for the crash log through `captureError`.

* fix(error-guards): forward the first stdio failure to the crash log

createDiagnosticLogger holds an `error` listener on stdout/stderr, so it
consumes the stream failure before the process guards can see it. Once the
sink latched closed, the failure was dropped from the crash log entirely:
a closed stdout pipe produced one report before the sink guard landed and
none after.

- Add an `onError` option to `createDiagnosticLogger`. It latches the sink
  closed first, then forwards only the first stream error; a throwing
  reporter cannot escape.
- Main process: report the failure through
  `crashLogBridge.captureError("stdio", err)`.
- Terminal worker: fold the inline `consoleBroken` latch onto
  `createDiagnosticLogger` and forward the first failure through
  `report`/`reportWorkerError`.

Regression coverage spawns a subprocess whose stdout/stderr pipe is closed
by the parent and asserts exactly one report is produced.

* ci: retrigger tests after unrelated plugin SIGKILL flake

PluginLogSettings.test.tsx was SIGKILL'd and the lint-and-test job was
canceled; the failure is unrelated to the stdio guard change.

---------

Co-authored-by: shibo.li <shibo.li@d-robotics.cc>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-11-05 21:34:37 +08:00
陈大猫 0e3f282657 fix(terminal): stop Shift+Insert CSI leaking to broadcast peers (#4142)
Fixes #4142

Squash-merge gate:
- GH Codex CLEAN on tree-identical tip c59ea270e (receipt 6653109730)
- CI SUCCESS https://github.com/binaricat/Netcatty/actions/runs/41995749622
- Electron smoke PASS /workspace/netcatty-smoke-shots/pr-4143-c59ea270e (marker both panes; Insert CSI absent)
2026-11-05 16:49:02 +08:00
陈大猫 4eceeda4b7 fix(terminal-worker): stop EPIPE ignore path from log-feedback looping (#4137)
Fixes #4136.

When the terminal utilityProcess console pipe is already dead, classifyProcessError marks EPIPE / ERR_STREAM_DESTROYED as ignore, but installTerminalWorkerErrorGuards still called logError → console.error. That write failed with EPIPE, re-entered uncaughtException, and busy-looped.

- On action === ignore: only report, never logError.
- Latch consoleBroken on stdout/stderr error so the default console.error path no-ops after the transport dies.
- Harden compressed extract cancel rollback under SIGTERM (ignore HUP/INT/TERM during cleanup, unlink archive first) and stabilize the FIFO cancel test that was flaking CI.

Out of scope: #4128 main-process EIO twin.
2026-11-05 11:58:00 +08:00
陈大猫andbinaricat c357d14181 fix(transfer): keep SSH alive when canceling mid-transfer (#4134) (#4135)
* fix(transfer): keep SSH alive when canceling mid-transfer (#4134)

Cancel of a large isolated SFTP upload/download used to call sftp.end()
mid-WRITE on a channel that shares the terminal SSH transport. That
CHANNEL_CLOSE with in-flight traffic can make the remote drop the whole
connection, so the shell shows [session closed by remote (no exit status)].

Abort now drains like the shared browse path (no mid-flight end); dispose
only after settle. Cancel returns { cancelled: true } so the pool does not
treat channel-closed as session loss.

* fix(transfer): deferred-dispose soft-cancelled isolated SFTP (#4134)

Codex P2 on 88b4890: softDispose dropped cancelled isolated download
channels from the pool without ending them, leaking remote subsystems.
Schedule sftp.end() after a ~2s quiet settle instead of abandoning forever
or ending mid-flight on the shared SSH transport.

Aligned cheap fix for isolated fastPut cancel: after force-settle, defer
end of the disposable channel the same way. Stopping ssh2 fastXfer
internal scheduling still needs invasive hooks — left as documented
residual; deferred end after quiet is the minimal leak fix.

Tests cover mid-flight isolated upload/download cancel (no end while
held) and eventual deferred end after settle.

* Revert "fix(transfer): deferred-dispose soft-cancelled isolated SFTP (#4134)"

This reverts commit 5b85ee70c0.

* fix(transfer): dispose soft-cancelled SFTP only after request drain (#4134)

Codex 4475686359 on 5b85ee70: fixed 2s deferred sftp.end() still fired while
READs were pending and could recreate the shared-SSH drop. Poll sftp._requests
for quiescence before ending; if still pending past the hard max, skip end
(abandon until session close) rather than ending mid-flight.

Tests: held isolated download READs past 2s must not end until released;
fastPut gate cancel still deferred-ends after drain.

* Revert "fix(transfer): dispose soft-cancelled SFTP only after request drain (#4134)"

This reverts commit 7e9934cd7e.

* chore: retrigger CI after lint-and-test runner cancel (#4135)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-05 10:44:57 +08:00
陈大猫 e7ade527ee fix(ui): keep follow-system themeSource from chrome override (#4131)
When Interface Theme is Follow System, chrome refresh called setTheme(light|dark) and locked nativeTheme.themeSource, so OS appearance flips no longer updated the UI. Keep setTheme('system') on system chrome preference.

Fixes #4130

Gate: GH Codex CLEAN on 2c0c1c77fd (6635017901); CI 41860537287 success; Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4131-37c0f491a (same tree as prior tip).
2026-11-05 09:10:11 +08:00
陈大猫 54c4db5030 fix(pty): forward childProcesses queries to terminal worker (#4127)
Worker-owned PTY sessions register their PIDs in the worker, leaving the main process map empty during close-busy checks.

Forward child-process queries to the terminal worker and register a worker handler for string and object payloads. Fall back to the main process tree if the worker is unavailable or returns a non-array result.
2026-11-04 04:25:48 -08:00
陈大猫 e17d7f01b6 perf(terminal): cache ligature font parses across terminals (#4126) 2026-11-04 04:01:36 -08:00
陈大猫andClaude Opus 5.5 5098f53944 perf(renderer): cut the startup bundle by a third (#4120)
* perf(renderer): cut the startup bundle by a third

Every window parsed ~6.1 MB of JavaScript before the first render. The entry
chunk carried all five UI locales, and the whole AI SDK chunk was preloaded
even though the AI panel is lazy.

- i18n: only English stays in the entry; other dictionaries load on demand
  (plus their same-base fallback, e.g. zh-TW -> zh-CN). index.tsx awaits the
  saved language before the first render, and I18nProvider re-renders when a
  runtime switch finishes loading.
- AI runtime: aiStateSnapshots cleaned up chats through getAgentRuntime(),
  and the plugin AI/agent hosts created their runtimes on mount, which pulled
  the Catty harness and @ai-sdk providers into startup. Cleanup now goes
  through a light handle that the runtime registers (the reversible privacy
  flag and main-process privacy mapping are still cleared without a runtime),
  and both plugin hosts create their runtime on the first request, re-checking
  liveness after the async load.
- Chunking: manualChunks' object form also moved Vite's dynamic-import
  preload helper into vendor-ai (the AI SDK itself uses import()), so the
  entry imported it from there and vendor-ai (649 KB) was preloaded on every
  launch. Vendor chunks now use the function form and the helper gets its own
  1 KB chunk.
- jsqr is loaded when a QR image is imported instead of with the host editor.
- startupBundle.architecture.test.ts guards these invariants.

Startup JS (index.html): 5.29 MB entry + 649 KB vendor-ai + 138 KB radix ->
3.80 MB entry + 1 KB helper + 174 KB radix (-35%). Startup harness, 2000 hosts,
10 runs, warm p50: loadURL -> renderer entry evaluated 321 -> 207 ms,
loadURL -> renderer ready 481 -> 323 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(i18n): preload locale messages before static rendering

* test(i18n): preload locales in remaining zh-CN UI tests

Non-English dictionaries now load lazily, so I18nProvider can initially render English before its effect finishes loading zh-CN. Explicitly await loadLocaleMessages before rendering these localized UI tests to fix the remaining CI failures without timing-based waits or production changes.

* chore: retrigger CI after locale preload fix

Previous tip 38108ce66 was on the branch but the PR head stayed
stuck on d49ad024e after a GitHub 500 during push. Empty commit
to refresh the PR head and re-queue checks.

* chore: retrigger CI after plugin-contract runner cancel

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 03:19:21 -08:00
陈大猫 c1df8a6011 fix(ai): keep sessions past 2MB localStorage budget via overflow (#4123)
Urgent fix for #4123: retain AI chat history past the 2MiB primary localStorage budget via overflow chunks + pre-split disk backup, with disk-only recovery markers, aggregate-quota degradation, import live-state retention, and count-cap marker resolution.

Gate: local Codex CLEAN (no P1) on 084ed813; CI green on acabe0bbc; Electron smoke PASS (pr-4124-acabe0bbc). Residual P2s noted (cross-window reconcile; vault-restore marker clear; retention-cap during pending recovery).

No release.
2026-11-04 02:41:44 -08:00
陈大猫andClaude Opus 5.5 a8bb5a43fc perf(main): stop blocking the first window on validator compilation (#4115)
The main process ran ~1.4 s of synchronous work in registerBridges before the
main window could start loading (app:// assets are served from the main
thread too), even for users without plugins:

- contractValidator: every createDefinitionValidator() built a fresh Ajv
  instance and re-registered the 200 KB plugin contract (~17 ms each, ~90 of
  them across the plugin host at startup, plus one per plugin view resource
  request). Share one Ajv per process, compile each definition on first use
  and memoize it; each validator still exposes its own `.errors`.
- distributionSnapshot: compile the three distribution schemas lazily on the
  shared contract Ajv instead of a second Ajv at module load (273 ms -> 37 ms).
- pluginBridge: hoist the PluginViewResourceRequest validator to module scope.
- cloudSyncBridge: load webdav / @aws-sdk/client-s3 / @smithy/node-http-handler
  on first sync instead of at startup (108 ms -> 4 ms).
- app:// scheme: enable codeCache so V8 reuses compiled renderer bundles
  across launches and windows. A same-length in-place edit of the cached entry
  chunk runs the new code, so stale cache is not reused after upgrades.

Startup harness (2000 hosts, 10 runs, warm p50): registerBridges 1421 ->
116 ms, plugin host 871 -> 30 ms, renderer ready 2147 -> 723 ms, total memory
923 -> 823 MB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 02:33:16 -08:00
b87360783a perf: add opt-in startup timeline and real-app startup harness (#4114)
* perf: add opt-in startup timeline and real-app startup harness

- electron/perfTrace.cjs records first-occurrence startup marks in the main
  process when NETCATTY_PERF_TRACE=1 (a no-op otherwise), then writes the
  main-window renderer timeline (navigation, paint, netcatty:* marks, JS heap)
  and app.getAppMetrics() to JSON after the first renderer-ready signal.
- Marks: main.cjs entry / modules loaded / whenReady, main window creation,
  registerBridges and plugin-host start/end, loadURL, did-finish-load,
  renderer ready, window shown. The renderer adds three performance.mark()
  calls (entry evaluated, App mounted, renderer ready), also visible in
  DevTools.
- scripts/perf-startup.live.cjs (npm run perf:startup) launches the built app
  N times against a throwaway --user-data-dir profile with ssh:// deep-link
  registration disabled, optionally seeds synthetic hosts over CDP, and
  reports cold plus warm p50/p90.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(harness): add --profile option to reuse a startup profile

Lets the harness keep one profile across invocations, e.g. to compare warm
V8 code caches across builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after flaky monitorRuntimeIsolation failure

Previous lint-and-test failed on known-preexisting
`useEffect is not defined` in monitorRuntimeIsolation.test.ts.

* chore: retrigger CI after monitorRuntimeIsolation useEffect flake

Known pre-existing flake: monitor controls test fails with
`useEffect is not defined`. Empty commit to re-run once.

* test(app): bind React hooks in monitorRuntimeIsolation VM sandbox

Extracting component declarations drops their React imports, leaving bare hooks such as useEffect undefined in the VM. Inject common React hooks and APIs while preserving explicit binding overrides to prevent intermittent monitor isolation failures in CI.

Stub the startup performance mark through App test bindings so the mount effect can execute without browser globals. Production components remain unchanged.

Validation: monitorRuntimeIsolation passed five consecutive runs. Plain npm run lint found 17 existing errors in git-ignored scratch files; excluding only _t.mjs and sim-4107.mjs passed with zero errors and eight existing warnings.

* chore: retrigger CI after plugin-contract runner cancel

Lint/schema passed; plugin-contract step cancelled by the runner
(not a test failure). Empty commit to re-queue.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-04 02:15:34 -08:00
dc5fcfbc19 chore: fix outstanding ESLint warnings (#4112)
* chore: fix outstanding ESLint warnings

- useSftpFollowTerminalCwd: drop activeSessionId/focusedSessionId from the
  syncFollowToTerminalCwd deps; the callback reads them through refs and the
  only consumer effect already re-runs on those ids.
- useSftpPaneDialogs: list the stable setShowDeleteConfirm callback in the
  handleDelete/openDeleteConfirm deps.
- csvProxy: remove the unnecessary `\[` escapes inside character classes
  (same matches under the `u` flag).
- Remove unused test imports/variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after runner SIGTERM cancel

Previous lint-and-test was cancelled by runner shutdown, not a real failure.

* chore: retrigger CI after second runner cancel

Lint had already passed; plugin-contract step was cancelled by the runner.

* chore: retrigger CI after third runner cancel

Lint and schema steps passed; plugin-contract step was cancelled by
the runner again. Empty commit to re-queue lint-and-test.

* chore: retrigger CI after repeated runner cancel

plugin-contract step keeps being cancelled by the Actions runner
(not a test failure). Empty commit to re-queue; Actions rerun API
denied for this PAT.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-04 02:11:09 -08:00
fcfba34fc1 perf(state): stop handler churn on session updates and repeated JSON work (#4121)
* perf(state): stop handler churn on session updates and repeated JSON work

- Shell handlers: AppSideEffects registers ~58 glue callbacks for the Host
  islands. Several close over sessions/workspaces/hosts (split/copy/duplicate,
  close tabs, delete host, keyboard-interactive, connect...), so every session
  status change and host update (connect bursts, last-connected times)
  re-notified the bridge and rebuilt all four shell bags, re-rendering AppView
  and TopTabs. They are now registered behind per-key stable wrappers that
  call the latest implementation (stabilizeAppHandlers); local callbacks and
  their effects are unchanged. All registered entries are actions, setters or
  refs, never read during render.
- Session actions: moveFocusInWorkspace, copySession and copyWorkspace read
  workspaces / orphan sessions / log views through refs at call time (the same
  snapshot the closure captured), so they keep a stable identity. Getters read
  during render (isBroadcastEnabled, getOrderedWorkTabs, ...) are untouched.
- AI history: the live store was parsed five times when the AI state root
  mounted; parseSessionsStore parses once per call (two parses total).
- AI history writes: the synced branch serializes once and reuses the string
  for the disk mirror, and rewriting the same immutable sessions array (save
  path, then cloud-sync collection) is skipped while storage still holds that
  exact write in the same mode.
- Command history: saving after each Enter parsed the whole store (up to 10k
  entries) only to read its clear generation, and every other window parsed it
  again on the storage event. The generation is now serialized first and read
  from the raw string (full parse only to adopt a peer clear), and the
  debounced write runs when the renderer is idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after cancelled lint-and-test run

Previous lint-and-test was cancelled mid-run.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-04 01:11:31 -08:00
陈大猫andClaude Opus 5.5 fa554888a6 perf(terminal): stop per-second churn while connecting and while idle (#4118)
* perf(terminal): stop per-second churn while connecting and while idle

- Connecting sessions: the 200 ms progress animation and 1 s countdown were
  Terminal state threaded into the TerminalView ctx, so every connecting
  session (hidden tabs and startup restores included) re-rendered Terminal +
  TerminalView up to six times a second. Terminal now only publishes the
  timeout deadline and real progress events; the connection dialog animates
  and counts down locally (connectionTicker.ts). HostConnectionTestDialog
  keeps passing progress/timeLeft as before.
- Monitor runtime: polled every second unconditionally and woke every
  subscriber each time because takenAt always changes. It now polls at 1 s
  only while something is monitored (here or in another window), 10 s
  otherwise, and skips notifying for unchanged idle replies.
- Auto-reconnect progress log: capped at the newest 300 lines (it grew by a
  few lines every 5 s retry for as long as an outage lasted).
- App Lock: report activity at most once a second instead of an IPC per
  keystroke / wheel tick / pointer event.
- Tray/Dock: the renderer resends hosts only when the host list changed, and
  the main process rebuilds the macOS Dock menu (sort + one native item per
  host) only then, not on every session status change.

Real app, profiling build, main -> this change:
  3 SSH sessions connecting (3 s)   14 commits / 35.3 ms -> 8 / 1.7 ms
  idle (10 s)                       13 commits / 3.1 ms  -> 2 / 0.1 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(terminal): update observe-popup reconnect deps assertion for capped progress logs

CI failed because #4118 wrapped setProgressLogs in useCallback to apply
capConnectionProgressLogs and added it to scheduleAutoReconnect deps.
The observe-popup source assertion still expected the old deps list.

The attachExistingSession early-return behavior is unchanged; only the
stale dependency regex needed updating.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 01:11:25 -08:00
陈大猫andClaude Opus 5.5 d56150953d perf(terminal): cap live WebGL contexts, release them eagerly, bound note undo history (#4122)
* perf(terminal): cap live WebGL contexts and stop the context-loss cascade

Chromium keeps at most 16 WebGL contexts per renderer and force-loses the
one that drew least recently past that. Hidden terminal panes keep their
WebGL renderer unless hibernation is on, so with more than 16 terminal
tabs every forced loss was rebuilt 50 ms later, evicting another context.
With 20 local tabs, half of all tab switches landed on a lost context
(xterm waits 3 s before falling back), and terminals that lost three
contexts within a minute were parked on the DOM renderer for good.

- Add a shared context budget (12). Past it, renderers of panes hidden for
  at least 3 s are released, least recently seen first. Visible panes are
  never released or refused; a released pane rebuilds when shown.
- A context lost while hidden waits for the reveal instead of rebuilding
  at once, and no longer counts toward the breaker.
- The short-hide CSS reveal path now also asks for the renderer (a no-op
  while it is live).
- Disposing the addon left its context alive until GC. Lose it explicitly
  so GPU memory and the Chromium slot come back right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(notes): bound the source editor's undo history

Every undo step in the markdown source editor holds a full copy of the
note, and the stack never shrank. A long session on a large note kept
every intermediate version alive (a 100 KB note with 1,000 steps holds
~200 MB of UTF-16 text).

Keep the 200 most recent steps and at most 8M characters across them,
dropping the oldest first. The newest step always survives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:22:42 -08:00
陈大猫andClaude Opus 5.5 1b9c744fda perf(ui): stop whole-shell re-renders on clicks, focus, typing and modifiers (#4117)
Several everyday interactions re-rendered the entire shell (AppView, TopTabs,
TerminalLayer and every pane) even though nothing they show had changed:

- Clicking or focusing a split pane always replaced the workspaces array
  (twice per click: onClick + onFocusCapture), cascading through the whole
  app and re-saving the session restore snapshot. setWorkspaceFocusedSession
  and moveFocusInWorkspace now keep the same array when focus is unchanged
  (domain/workspace.ts setWorkspaceFocusedSessionId).
- Every window focus/visibility change resynced app lock and produced a new
  runtime state object even when nothing changed, re-rendering the provider
  tree. Equal states keep the current object and useAppLockState's result is
  memoized.
- Quick switcher keystrokes went through AppSideEffects state and the
  app-local UI store, re-rendering AppSideEffects, every host island, AppShell,
  AppView and TopTabs per character. QuickSwitcherOverlay now owns the query
  and host results; the overlay unmounts on close, which resets the query
  exactly where setQuickSearch('') used to.
- Pressing/releasing Ctrl/Cmd (every ⌘C/⌘V/Ctrl+C) re-rendered the whole tab
  bar to toggle shortcut number badges. The held flag now lives in a small
  store read only by each tab's TabIconSlot.
- AppView passed fresh inline callbacks that defeated TopTabs' memo comparator
  and every open editor tab; TextEditorTabView is now memoized. TopTabs
  memoizes per-workspace session lists and hoists the root tab icons.
- Workspace drag previews only update state when the drop hint actually moves.

React commits / render time for 10 repetitions in the real app (profiling
build, 2 split panes, 300 hosts), main -> this change:
  window focus            42 / 17.1 ms -> 2 / 0.4 ms
  press/release Cmd       50 / 41.9 ms -> 22 / 6.6 ms
  click focused pane      42 / 69.6 ms -> 3 / 0.4 ms
  quick switcher typing   41 / 61.3 ms -> 16 / 34.8 ms

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:22:10 -08:00
陈大猫andClaude Opus 5.5 2318434ac9 perf(ai): stop freezing the app during agent discovery and chat backups (#4116)
Both paths ran synchronous I/O on the Electron main process, which freezes
every window and the terminal keystroke relay while it runs:

- Agent discovery (opening the AI panel or Settings > AI) probed auth with
  execFileSync: `security find-generic-password` (4 s timeout), `gh auth
  status` (network, 5 s), `which cursor-agent` (4 s) and `cursor-agent status`
  (8 s), one after another. The probes now use a non-rejecting async execFile
  helper with the same result shape; discovery awaits them.
- Cursor SDK detection evaluated `import("@cursor/sdk")` (~4 MB of ESM plus
  its transport stack) only to check that it is installed, and kept it
  resident afterwards. It now checks for the ESM entry that the dynamic import
  loads; the SDK itself still loads on first use. The package does not export
  package.json and its CJS build is stripped from releases, so the check walks
  require.resolve.paths() instead of using require.resolve.
- The AI chat backup wrote the whole history (often several MB) with
  writeFileSync on every debounced change. Writes now use fs.promises and are
  serialized so generations cannot interleave.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:21:50 -08:00
陈大猫 dcf558e685 fix(terminal): clear sticky broadcast latch on vim ex (#4107) (#4109)
Fixes #4107.

Merge gate: GitHub Codex clean on tip b7efbe347; local CLEAN residual (unknown colon-prefixed alt-screen challenges) left as documented thrash; CI green (lint-and-test / windows-fido / windows-broadcast-ssh); Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4109-b7efbe347-20261104-122316 (both panes exited vim via toolbar broadcast :q!).
2026-11-03 20:38:28 -08:00
csr588 7683874a4e feat(autocomplete): add zh-CN descriptions for touch (#4084) 2026-11-03 19:09:25 -08:00
陈大猫 3ac24f7aa2 fix(ai): never pass empty messages to AI SDK standardizePrompt (#4102)
Fixes #4102

Gate: GitHub Codex CLEAN (6598986796) on 322485f; CI 41604002300 SUCCESS; Electron smoke PASS NC_OUT=/workspace/netcatty-smoke-shots/pr-4103-322485f-20261103-163619 (local Codex CLI geo-403).
2026-11-03 00:49:24 -08:00
陈大猫andbinaricat 79a6e14f7d fix(vault-backups): clarify folder is not portable alone (#4097)
* fix(vault-backups): clarify folder is not portable alone (#4096)

* ci: retrigger lint-and-test after runner cancel (#4096)

* ci: retrigger lint-and-test after runner cancel (attempt 3, #4096)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-02 17:55:42 -08:00
github-actions[bot]andgithub-actions[bot] 8043178d54 chore: update star history charts [skip ci] (#4087)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-02 00:07:46 +00:00
陈大猫 9fb2a5c8c9 fix(terminal): honor Settings custom shell when Terminal button clicked (#4085) (#4086)
* fix(terminal): honor Settings custom shell when Terminal button clicked (#4085)

Vault/host-tree Terminal buttons wired onClick={handler}, so React passed the
MouseEvent as the shell arg and skipped resolveShellSetting(terminalSettings.localShell).
Only treat objects with a string command as explicit shells; wrap onClick sites.

* ci: retrigger after lint-and-test runner cancel (#4085)

* test(terminal): accept wrapped local-shell onClick in host-tree toolbar (#4085)

Codex GH review: source assertion still expected onClick={onCreateLocalTerminal}
after wrappers; update to () => onCreateLocalTerminal() so npm test passes.
2026-11-01 06:04:01 -08:00
陈大猫andbinaricat 70ce1258f9 fix(tray): do not force-show main window when starting port forward (#4081) (#4082)
Only call openMainWindowReady() when the runtime is locked (UI needed to
unlock). Unlocked tray starts deliver via sendToMainWindow with focus:false
so a hidden/minimized main window stays in the background.

Fixes #4081

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-31 21:14:33 -07:00
陈大猫andbinaricat c01d057a7d fix(mcp): recognize Oh My Zsh ➜ idle prompts (#4079)
* fix(mcp): recognize Oh My Zsh ➜ idle prompts

Oh My Zsh robbyrussell paints idle prompts like `➜  ~` / `➜  ~ git:(main) ✗`.
The POSIX idle whitelist never matched leading Unicode ➜, so MCP
terminal_execute stayed SHELL_NOT_READY after a session switched into zsh.
Accept common robbyrussell ➜ lines (two-space theme form and `➜ ~`) and
still reject pending input and bare ➜.

Fixes #4078

* chore: retrigger CI after unrelated PluginAIContextHost flake

* chore: retrigger CI (lint-and-test hung after cancelled plugin-verify step)

* chore: empty commit to retrigger CI

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-31 05:22:08 -07:00
陈大猫 cd6e1c6990 fix(mcp): recognize Powerlevel10k ❯ idle prompts (#4076)
Powerlevel10k paints idle prompts like `~ ❯` / `alice@host ~/src ❯`.
The POSIX idle whitelist never matched Unicode ❯, so MCP terminal_execute
stayed SHELL_NOT_READY while bash `$` worked. Accept common p10k ❯ lines
and still reject pending input and bare ❯.

Fixes #4075
2026-10-31 01:38:41 -07:00
陈大猫 42c69902e8 fix(ai): strip Anthropic signed thinking when context prefix mutates (#4073) (#4074)
* fix(ai): strip Anthropic signed thinking when context prefix mutates (#4073)

Anthropic preserved-thinking signatures bind to the exact conversation
prefix. Step compression/prune and durable summary keep-tail were
replaying later signed blocks after mutating earlier tool_results,
causing stable 400 Invalid signature errors on long tool chats.

Strip signed reasoning when prepareStep didAdjust, skip signatures on
durable-summary tails, and one-shot retry after matching 400.

* fix(ai): propagate Anthropic signature stream errors with progress-aware retry (#4074)

P1-1: rethrow signature failures from cattyStreamProcessor error chunks
(and reader catch) via createCattyAnthropicSignatureRetryError so the
turn-driver strip-and-retry path can see them.

P1-2: mirror the 413 hadToolProgress path on signature retry — rebuild
from latest session, retain completed tool results, new assistant
message, and enable write replay before stripping/retrying.

* fix(ai): preserve plain-object Anthropic signature messages in retry wrapper (#4074)

* fix(ai): only strip Anthropic signed thinking when provider allows removal (#4074)

DeepSeek Anthropic-compat requires thinking blocks with replayed tool calls;
gate durable-summary and prepareStep strips behind providerAllowsAnthropicSignedThinkingRemoval. Keep 400 invalid-signature strip-and-retry unchanged.

* chore: retrigger CI after runner shutdown cancel on lint-and-test (#4074)
2026-10-30 21:36:19 -07:00
陈大猫andbinaricat 780d297591 fix(terminal): forward xterm onBinary for legacy mouse (#3811) (#4066)
* fix(terminal): forward xterm onBinary for legacy mouse (#3811)

Legacy DEC mouse (ttymouse=xterm2) is emitted via onBinary; only onData
was wired. Preserve 0-255 through the write path with a binary/latin1
Buffer so high coordinate bytes are not UTF-8-corrupted.

* chore: retrigger CI after lint-and-test runner shutdown (exit 143)

* chore: retrigger CI after lint-and-test runner shutdown (2)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-30 07:11:27 -07:00
GOUKI9999 b496b00d2f feat: bounded recovery for scoped disconnected sessions (#3935)
Contributor GOUKI9999 session_reconnect/session_recover. Maintainer gate fix: do not report ready while still offline/connecting (pending instead); connected keeps advisory shellReady; regenerate capability tool specs. Unit tests 12/12. Electron smoke PASS (SSH shell path). Residual P2 hung-worker readiness left timeout-bounded. No release.
2026-10-29 19:32:02 -07:00
Joseph Hau a8c69b8e11 docs: propose Context Chips for the terminal compose bar (#4059)
* docs: propose terminal compose bar Context Chips

* docs: fix Context Chips baseline and demo anchoring
2026-10-30 10:09:24 +08:00
6d687d570f fix(sftp): keep non-ASCII local paths intact in Windows compressed upload (#4057)
* fix(sftp): keep non-ASCII local paths intact in Windows compressed upload

Windows tar.exe converts argv to the ANSI code page, turning non-ASCII
characters into '?'. Run tar with cwd instead of -C and stream the
archive through stdout instead of -f, so local paths never pass through
argv. Folders whose own name is non-ASCII fall back to per-file upload
on Windows.

* fix(sftp): exclude in-tree tar archive under GNU tar stdout

* fix(sftp): resolve trusted local tar before cwd switch

Use the Windows System32 tar.exe for local availability checks and compression so an upload sibling tar.exe cannot hijack execution. Keep Unix tar lookup unchanged and update command assertions.

Residual P2: exclude patterns still interpret glob metacharacters in archive paths; glob escaping is intentionally deferred.

* ci: retrigger lint-and-test after stuck runner

* ci: retrigger after unrelated plugin manager UI flake

---------

Co-authored-by: noon.li <214648221+lyon-le@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-29 05:38:59 -07:00
陈大猫 a2f23054df fix(sftp): keep non-ASCII local paths intact in Windows compressed upload (#4058)
Gate for contributor #4057 (same tip). cwd+stdout FD instead of -C/-f for Windows tar.exe ANSI argv; GNU tar --exclude for in-tree archive; resolve System32 tar.exe before cwd switch.

Local Codex CLEAN on 73ff25a31 (residual P2 glob-escape deferred). Electron smoke PASS NC_OUT pr-4057-73ff25a31. CI green on tip after plugin-manager focus flake retrigger.
2026-10-29 05:38:44 -07:00
Joseph Hau 0fe069e575 fix(terminal): preserve output timestamps after clear (#4056)
* fix(terminal): preserve output timestamps after clear

* fix(terminal): limit timestamp checkpoints to erases
2026-10-29 02:08:05 -07:00
GOUKI9999and陈大猫 1809b7dc02 fix(sync): keep full AI chat history when cloud sync is enabled (#4047)
* fix(sync): keep full history when AI chat sync is enabled

Retain all messages when cloud AI-session sync is enabled; keep the 500-message limit for local-only chats. Cover both settings with a history-retention regression test.

Rebase onto origin/main and retain #4045 preserveActive behavior and its no-cancel regression coverage unchanged. Drop the duplicate wait-before-merge loop, Waiting-for-Agent UI and i18n, sync status and streaming wait helpers, hasActiveTurn, wait-only tests, and docs plan.

Validation: all 23 tests pass across useAIState.historyRetention.test.ts, syncPayload.aiSessions.test.ts, and agentStop.test.ts. Targeted ESLint and git diff --check pass.

* chore: retrigger CI after cancelled lint-and-test

Previous Actions run 40824800880 cancelled mid plugin-contract step; windows jobs were already green. No code change.

* chore: retrigger CI after cancelled lint-and-test (Codex local PASS)

---------

Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-28 23:58:42 -07:00
陈大猫 d5a03606b7 feat: rebuild plugin UI with host rendering and standard settings (#4053)
* feat: rebuild plugin pages with host rendering and native settings

* fix(plugins): contain host-view errors, isolate editing, and migrate fixtures

* fix(plugins): migrate remaining permission fixture hosts
2026-10-28 23:08:31 -07:00
陈大猫 7cd57adcea fix(terminal): PTY OSC7 install when exec cannot verify shell (#4052) (#4054)
Fixes #4052. Electron smoke PASS; Codex CLEAN; CI green.
2026-10-29 12:48:32 +08:00
陈大猫 7d5d234fdd fix(host-browser): accept invalid TLS certs for page tabs (#4050)
Fixes #4049

Host Browser page tabs accept self-signed / private-CA certificate errors so HTTPS device UIs load.

Gate: GitHub Codex clean + local Codex CLEAN tip 5ec2bd4a0; CI green; Electron smoke PASS NC_OUT pr-4050-5ec2bd4a0-cert-r2.
No release.
2026-10-28 19:12:01 -07:00
陈大猫 eb56936e9b Prevent cloud sync from stopping active agent turns (#4045)
Fixes #4044.
2026-10-28 04:13:49 -07:00
陈大猫 6da42ebc53 feat(settings): custom temp directory for SFTP staging (#4035)
Fixes #4033

User-configurable Netcatty temp root (Settings → System → Temporary Files) with backward-compatible default, Choose folder / manual path / Reset. Electron smoke PASS tip f6f32b69.
2026-10-28 03:02:39 -07:00
陈大猫andCursor f0483488e1 feat(plugins): polish manager metadata and support verified README images (#4043)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 16:32:41 +08:00
陈大猫 263133f2ea Fix select-before-connect for grid and list hosts (#4042)
* Fix select before connect in host collections

* ci: retrigger lint-and-test (notes persistence flake unrelated to #4042)

* chore: retrigger CI after unrelated NotesManager.persistence flake
2026-10-28 01:28:27 -07:00
陈大猫 887c5779b5 fix(ai): submit PowerShell agent wrapper with CR only [4036]
* fix(ai): submit PowerShell agent wrapper with CR only [4036]

* ci: retrigger lint-and-test after runner cancel [4036]

Previous lint-and-test job was cancelled mid-step (Lint passed; Test skipped).
No code change.

* ci: retrigger lint-and-test (stuck cancelled job on prior tip) [4036]
2026-10-28 01:03:28 -07:00
27f26d8e69 feat(monitor): 优化磁盘IO展示与系统分区告警 [4030] (#4031)
* feat(monitor): 优化磁盘IO展示与系统分区告警 [4030]

* fix(monitor): 修复磁盘监控评审问题 [4030]

* fix(monitor): align Partitions bars with Memory; hide bare / label

Label-above-bar for partition list; single-root `/` hides the label and
centers the bar like Memory/CPU (unchanged). Multi-disk keeps top align +
system alerts. Disk IO adds a spacer row to track usage labels.

Review crops under .github/review/ for PR discussion (drop before merge OK).

* fix(monitor): keep bare-/ align; multi-disk labels stay left

Address Chris density feedback: keep the single root label hidden and vertically centered with Memory, and restore side-by-side labels for multiple disks. Remove the Disk I/O spacer and update the review crops.

* chore(monitor): drop PR #4031 discussion review crops

---------

Co-authored-by: wendeng.dong <wendeng.dong@sailuntire.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-27 23:54:58 -07:00
陈大猫 30a74f7c1d refactor(settings): simplify AI tool access configuration (#3957)
Move infrequent Tool Access / MCP / privacy / HTTP setup into focused dialogs; keep status and clear actions on the main page. Rebased onto current main after locale/settings conflicts.

Codex CLEAN (gpt-6.1-terra). CI lint-and-test green. Focused privacy/MCP/guidance tests passed on rebase tip. No release.
2026-10-27 20:59:11 -07:00
陈大猫 631d04b654 Fix Deck parenthesized prompt readiness (#4029)
* fix: recognize parenthesized Deck shell prompts

* fix: recognize Deck prompts with special path characters
2026-10-27 20:58:50 -07:00
陈大猫andJoseph Hau e98e236d54 fix(ai): recognize macOS zsh default prompts for PTY readiness (#3980)
Continuation of #3969 by @hexart (Joseph Hau). Preserves original commit recognition and adds spaces / %#→# coverage so idle macOS zsh terminals accept AI commands.

Codex CLEAN (gpt-6.1-terra) vs main. CI green. No release.

Co-authored-by: Joseph Hau <hexart@gmail.com>
2026-10-27 20:38:16 -07:00
陈大猫 1e063dfa3c fix(ai): stop automatic model discovery retries after failure (#4002)
Gate ModelSelector discovery on settled error; preserve explicit Refresh and config resets. Component regressions included.

Codex CLEAN (gpt-6.1-terra) vs main. CI green on tip. No release.
2026-10-27 20:38:13 -07:00
8b5b0c4416 fix(ai): keep dropped AI chats recoverable with dangling marking + /rebind; reuse workspace id on merge (#4019)
* fix(ai): keep dropped AI chats recoverable with dangling marking and /rebind

Closing a member terminal or dissolving a workspace used to clear the
chat's active pointer, making it invisible (scope rank 0) and
unrecoverable from the UI even though the record survived in storage.

- orphan cleanup now marks dropped chats `scopeDangling: true` and keeps
  record + active pointer intact; the flag clears when the target
  returns or the chat is rebound
- `/chats` lists dangling chats with a numbered inline affordance and
  hint line
- new `/rebind <n>` panel action rewrites `scope` to the panel's current
  scope, clears the marker and restores the active pointer
- i18n keys added for en / zh-CN / zh-TW / es / ru

Stacked on #4018. Refs #4015 (R2).

* fix(workspace): reuse existing workspace id when merging tabs with a workspace

createWorkspaceFromSessions silently no-opped when either merged tab
already belonged to a workspace (e.g. dragging a workspace pane onto a
standalone terminal), and the older dissolve->recreate behavior
regenerated the id that workspace-scoped AI chats bind to.

When one of the merged tabs has a workspace, the orphan tab now joins
that existing workspace (pane inserted next to the anchor session, tab
slot collapsed), so workspace-scoped chat bindings survive. Merging two
standalone tabs still creates a fresh workspace.

Stacked on #4018. Refs #4015 (R3).

* fix(ai): persist cleaned active map on dangling chat rebind

rebindSessionScope was updating React state to drop stale old-scope
pointers, then rebuilding localStorage from the pre-rebind snapshot and
resurrecting them. Persist the same cleaned map via rebindActiveSessionMap.

* fix(ai): persist rebind map inside setState; wire backup actions on terminal panel

- Write cleaned active-session map from inside the rebind updater so a
  deferred React setState cannot skip snapshot/localStorage persistence.
- Pass exportSessionsBackup/importSessionsBackup through TerminalLayerSupport
  and include them in AI_CHAT_SIDE_PANEL_AI_STATE_KEYS so history-drawer
  backup buttons work on the normal terminal path.

* fix(ai): guard backup restore races and use app-wide live targets for /chats

- Skip restore when in-memory sessions already exist; apply snapshot,
  storage write, and AI state emit atomically inside the empty-state path
  so peer useAIState instances see the restore.
- Thread validAIScopeTargetIds into AIChatSidePanel as liveScopeTargetIds
  so /chats does not mark chats bound to other open terminals as dangling.

* fix(ai): open rebound chat in panel view and refresh /chats listing

- rebindSessionScope also sets the panel view to the rebound session so an
  existing draft/other chat does not keep hiding the recovery.
- After /rebind, rewrite the stored listing instead of rebuilding from the
  stale render so the chat is no longer offered as dangling.

* fix(workspace): invert join split when dragged pane owns workspace; refresh /chats titles

- When merging a workspace-owned dragged pane onto a standalone tab, the
  orphan is inserted — invert the drop position so the previewed side matches.
- Compare workspaces in AIChatSidePanel memo so /chats picks up renames.

* fix(ai): validate live sessions array on boot; mark imported orphans dangling

- Reject parseable non-array store values (e.g. {}) via readLiveSessionsArray
  so boot restore is not blocked by a corrupt-but-truthy value.
- After manual import, re-run orphan cleanup with the last known live target
  set so chats bound to closed scopes become dangling and /rebind-able.

* fix(ai): merge imported sessions before orphan cleanup

Apply import merge to the snapshot/ref synchronously, then run dangling
marking, so a deferred setState cannot let cleanup rewrite over the import.

* fix(ai): mark restored/imported orphans dangling even with no live targets

Restore and import do not change live terminals/workspaces, so the orphan
cleanup effect will not re-run. Always call cleanup against the last known
live target set after those paths — an empty set is a valid observed state
(nothing mounted), not an unavailable snapshot — so chats bound to missing
scopes become dangling and /chats can offer /rebind.

* fix(ai): apply backup restore synchronously before dangling cleanup

Do not gate restore + orphan marking on a setState updater having run.
A deferred updater left didRestore false, skipped cleanup, and could leave
restored chats with missing scopes unmarked and unrebindable from /chats.

* fix(ai): complete /rebind from a partial slash picker selection

Selecting Rebind after typing `/re` left the composer unchanged, so a
following number became `/re 1` and could be sent to the agent. Replace the
slash query with `/rebind ` the same way Rename does.

* fix(ai): replace hostIds on rebind; skip non-array backup generations

- Rebind builds a fresh destination scope (including hostIds from the
  panel) instead of spreading the old scope, so history matching follows
  the new terminal/workspace host.
- readLatestGeneration treats valid JSON that is not a session array as
  unusable and continues to older generations, matching corrupt skips.

* fix(ai): revalidate dangling chat before /rebind from stale /chats listing

Skip rebind when the cached entry is deleted or already bound to a live
scope; drop stale listing entries so a second panel cannot steal the chat.

* fix(ai): reject malformed /rebind numbers

Require the entire trimmed argument to be digits so /rebind 1foo and
/rebind 1.5 show usage instead of silently targeting index 1.

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-27 20:26:21 -07:00
GOUKI9999andNetcatty Maintainer afb0585016 feat(ai): /rename & /chats slash commands + AI session durability layer (#4018)
* feat(ai): add /rename and /chats slash commands plus a session durability layer

Panel slash commands (#4015 feedback):
- /rename <title> renames the current chat via updateSessionTitle; the
  command is intercepted in ChatInput's system-command branch and never
  reaches the agent. Empty title shows a usage hint. The history drawer
  gains an inline rename affordance.
- /chats renders a dismissible system-style note listing the panel's
  visible chats (title / agent backend / workspace-or-terminal scope with
  a dangling marker / relative last-active time; current chat marked).
  Deliberately not named /resume: session.resume already means
  flow-recovery-after-cancel. Listing logic is pure (aiChatsListing.ts)
  so a future rebind action can attach per-entry actions.
- i18n keys added for en/es/ru/zh-CN/zh-TW.

Durability layer (additive; cleanup/eviction semantics untouched):
- Every successful store write schedules a debounced (~2s) mirror of the
  exact persisted JSON to {userData}/ai-sessions-backup/ through a new
  aiSessionsBackupBridge: atomic temp+rename writes, 5 rolling
  generations, timestamped names.
- On boot, a missing/empty live store is restored from the newest
  parseable backup generation. The asymmetry is deliberate: empty ->
  restore, non-empty -> leave alone, so restored data can never
  resurrect explicitly deleted chats.
- Manual export/import in the history drawer via real file dialogs
  (fixed-path fallback where dialogs are unavailable); import merges by
  id with newer updatedAt winning.

Tests: bridge rotation/corruption/rejection, restore decisions, merge
semantics, debounce throttle, mirror-on-write, slash interception and
/chats listing seams. eslint clean; tsc unchanged vs baseline.

* fix(ai): repair sessions backup IPC and restore/import guards

- Forward write/export payloads through preload unchanged so the main
  bridge receives a string json (was double-wrapped → invalid-json).
- Restore from disk only when the live store is missing or corrupt;
  treat a valid [] as an intentional clear. Flush the debounced mirror
  on beforeunload so deletions land before quit.
- Require id + messages[] + finite updatedAt when parsing backup/
  import entries so malformed objects cannot reach pruneSessionsForStorage.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): complete /rename from partial slash and harden import shape

- Selecting Rename from the slash picker replaces the active slash token
  with `/rename ` so a following title is intercepted, not sent to the agent.
- parseBackupSessions also requires title, agentId, and a scope object with
  a string type before accepting an imported/backup entry.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): validate backup messages and skip prune on synced restore

- Drop backup/import sessions whose messages lack id/role/timestamp.
- When cloud AI-session sync is enabled, restore parsed sessions without
  pruneSessionsForStorage so local retention cannot truncate synced history.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): export prepareRestoredSessions helper

Missed in the previous commit; required by useAIState boot restore and tests.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): require message content on backup import; raise disk cap to 64MB

* fix(ai): normalize corrupt sessions store to [] before boot restore

* fix(ai): skip non-array backup generations when reading latest

* fix(ai): emit sessions change on backup restore; skip unusable gens

* fix(ai): treat empty backup gen as wipe; align gen usability with parser

* fix(ai): seed backup on boot for live store; report durable import count

* fix(ai): emit sessions change after backup import

* docs(ai): clarify sessions backup recovers in-profile store only

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-27 20:04:42 -07:00
Joseph Hau 6d65c89ecf fix(appearance): 修复 macOS 托盘面板毛玻璃效果 (#4028) 2026-10-28 10:43:02 +08:00
陈大猫 2f214b8725 feat(plugin-agent): reuse host global Agent runtime with sandbox Chat sample (#4012)
Merge global Agent host runtime + sandbox Chat sample on top of #4011.

Electron plugin smoke PASS at fb691318c (S0–S4; S5 BLOCKED-FIXTURE). Codex CLEAN. No release.
2026-10-27 19:37:38 -07:00
陈大猫 350917fa0e feat(plugins): consolidate foundation and permission settings (#4011)
Merge plugin foundation + permission settings.

Electron plugin smoke PASS at stacked tip fb691318c (S0–S4; S5 BLOCKED-FIXTURE). Codex CLEAN. Marketplace artifact lifecycle deferred.
2026-10-27 19:37:19 -07:00
陈大猫 fb691318c4 chore(ci): retrigger lint-and-test after runner shutdown 2026-10-28 09:45:20 +08:00
陈奇 9c1af5c73f ci(plugin-agent): retrigger lint-and-test after runner shutdown
Empty commit only. Prior tip 57e682dc lint-and-test was cancelled by
runner shutdown mid-suite (no failing assertions). Re-runs via Actions
API are blocked for the fine-grained PAT; push retriggers PR checks.
2026-10-28 09:45:12 +08:00
陈大猫 555f193d23 fix(sync): block convergent AI session shrink after merge (#4025)
* fix(sync): block convergent AI session shrink after merge

Related #4017 — convergent smartMerge could drop many AI sessions
post-merge (e.g. 46→7) and still report success; classic syncAll
already guarded this. Gate upload/apply with detectSuspiciousShrink.

* fix(sync): drop explicit any in convergent shrink override test

* Fix blocked chat shrink advancing sync baseline
2026-10-27 10:36:19 -07:00
陈大猫 88dd20a918 Fix Enter delay with large command history (#4026)
* Fix command history Enter delay (#4022)

* Fix cleared shell history race
2026-10-27 10:11:05 -07:00
陈奇 57e682dc83 fix(agent): publish correlated names for unnamed tool results 2026-10-28 00:57:50 +08:00
陈奇 2671cf9f20 test(plugin-ai): deliver pagehide before closing fixture 2026-10-27 15:05:30 +00:00
陈奇 672a2bf406 merge: integrate verified plugin foundation into global Agent 2026-10-27 14:56:42 +00:00
陈奇 9f09af3bda Merge selected-context workspace focus correction 2026-10-27 14:29:42 +00:00
陈奇 5931eb14f0 fix(plugins): revalidate selected session context after workspace focus changes 2026-10-27 14:26:01 +00:00
陈奇 f5e2a64dfb fix(plugins): retain exact sample chat lease across modal reopen 2026-10-27 13:39:03 +00:00
陈奇 ca2280ea8b docs(plugins): record cancelled row redesign and preserved foundation fixes 2026-10-27 13:12:10 +00:00
陈奇 7e7b05620f Revert "feat(plugins): share borderless installed settings rows"
This reverts commit e4eb33856b.
2026-10-27 13:04:03 +00:00
陈奇 25e0c834da fix(plugins): preserve global chat transcript in compact modal 2026-10-27 12:51:07 +00:00
陈奇 1f0741698d docs(plugins): consolidate foundation landing ledger and sample provenance 2026-10-27 12:39:52 +00:00
陈奇 50fda91424 fix(plugins): invalidate author identity after rejected install refresh
Reuse the selection-token snapshot comparison for list and install, including a rejected stale or withdrawn selection. Preserve synchronous unavailable-source validation before the asynchronous list operation. The loaded-row regression rejects installation without downloading or executing a package.
2026-10-27 12:32:48 +00:00
陈奇 fc37ac48e1 Merge actual two-profile sync composition coverage into plugin foundation landing 2026-10-27 12:29:25 +00:00
陈奇 d035cbea38 Merge honest directory enumeration availability into plugin foundation landing 2026-10-27 12:29:25 +00:00
陈奇 b3895a477d Merge author identity refresh and borderless plugin rows into plugin foundation landing 2026-10-27 12:29:25 +00:00
陈奇 db1223d161 Merge verified main #4010 #4013 #4016 into plugin foundation landing
# Conflicts:
#	index.css
2026-10-27 12:29:25 +00:00
陈奇 14ed19d2ed test(plugins): compose two-profile settings sidecar collect and hydration 2026-10-27 12:23:21 +00:00
陈奇 a36a906292 fix(plugins): negotiate directory enumeration only with a secure adapter 2026-10-27 12:23:20 +00:00
陈奇 e4eb33856b feat(plugins): share borderless installed settings rows
Apply the exact six-file patch from implement_plugin_row_layout, based on 0c05a96fa1. Patch SHA-256: 5c58150fc3a7f72169b1a23ad1410cf37c51051b29bd6600bf30e848a9f366d6. Native visual acceptance remains pending.
2026-10-27 12:19:09 +00:00
陈奇 b14b65679f fix(plugins): invalidate mounted author identity on catalog changes 2026-10-27 12:18:27 +00:00
陈奇 f8616f5d3a fix(plugin-agent): clear stale status when conversation history changes 2026-10-27 12:11:17 +00:00
陈奇 0cab1046cf docs(plugin-agent): record composer correction package hash 2026-10-27 12:06:06 +00:00
陈奇 a56d1ab785 fix(plugins): skip destroyed WebContents during scope broadcasts 2026-10-27 12:04:34 +00:00
陈奇 cbe89995e6 fix(plugins): identify host operation and targets in Ask dialog 2026-10-27 12:02:41 +00:00
陈奇 72383a50c9 fix(plugin-agent): keep composer visible while transcript scrolls 2026-10-27 11:57:35 +00:00
陈奇 0c05a96fa1 fix(plugins): align avatar delivery with registry v2 contract 2026-10-27 11:48:35 +00:00
陈奇 05a773b426 docs(plugins): clarify default directory enumeration boundary 2026-10-27 11:25:54 +00:00
陈奇 da16b90e9e feat(plugins): add author avatars and host-controlled maintainer badges 2026-10-27 11:25:54 +00:00
2796d1113a fix(mcp): reconnect external calls that race a closed socket (#4016)
A peer FIN can leave readyState closed while destroyed is still false.
Concurrent tool calls wrote into that socket, hit writeAfterFIN, and
failed with "TCP connection lost" instead of sharing one reconnect.
Treat that socket as down, and make later callers join the in-flight re-auth.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 19:01:45 +08:00
陈奇 8bcad28f9d fix(plugin-agent): skip home config repair for delegated Claude turns 2026-10-27 10:18:51 +00:00
陈奇 59edc87ed4 fix(plugin-agent): wait for stopped driver before replacement turn 2026-10-27 09:30:21 +00:00
陈奇 5944a02238 fix(plugin-agent): bind startup and approved actions to current authority 2026-10-27 09:28:49 +00:00
陈大猫 c743456a0b Merge pull request #4013 from binaricat/feat/macos-frosted-glass-continued
feat(appearance): macOS frosted glass background (continues #4009)
2026-10-27 17:14:30 +08:00
陈奇 b17713ef95 merge: include foundation dialog handoff test gate 2026-10-27 09:10:09 +00:00
陈奇 56fefdd03f fix(plugin-agent): separate durable conversations from revoked runtime actors 2026-10-27 09:03:40 +00:00
陈奇 b7e4abb30b merge: carry isolated delegated transport review corrections into runtime
# Conflicts:
#	electron/bridges/mcpServerBridge.cjs
#	electron/bridges/mcpServerBridge.pluginDelegation.test.cjs
2026-10-27 08:50:20 +00:00
陈奇 f715a0764d feat(plugin-agent): wire core global conversations and minimal Chat sample 2026-10-27 08:49:54 +00:00
陈奇 5db0f90751 test(plugins): await Radix close handoff after the React commit 2026-10-27 08:47:23 +00:00
bincxzandCursor 336ab5ab17 fix(appearance): repaint native background on explicit theme pushes
Keeping the restore guard for registration-time chrome setup is right, but
it also swallowed explicit setTheme/setBackgroundColor pushes: windows that
had never enabled blur stopped repainting their native backing, leaving the
color stale after theme changes on every platform.

Separate the two call kinds with an explicit forceBackgroundColor flag so
registration still preserves the window's creation-time color while theme
and background-color pushes always land.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 16:46:32 +08:00
陈奇 63f0572e0b fix(plugin-agent): guard delegated cancellation and empty context paths 2026-10-27 08:45:57 +00:00
bincxzandCursor ce9fcdfec6 fix(appearance): sync macOS blur across windows and gate search by platform
Addresses review feedback on the frosted glass background setting.

- Detached #/session-window renderers run with settings sync disabled, so
  `applySharedWindowSetting` now forwards the blur key. Previously the main
  process put native vibrancy on those windows while their renderer class
  stayed stale, leaving them opaque with blur on and double-applying opacity
  with blur off.
- Only settings-owning windows persist the blur preference and drive
  `setWindowBackgroundBlur`, matching the other OS-effect guards. Detached
  windows still apply the transparent backing class locally.
- Search entries can declare `platforms`; the macOS-only blur setting is
  filtered out on Windows/Linux so a hit never jumps to a tab whose anchor is
  not rendered (including the default empty-query list).
- Restoring chrome only rewrites the background of windows this module made
  transparent, falling back to the active theme color when no theme color has
  been pushed yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 16:21:07 +08:00
Joseph Hau de06a6aa4d feat(appearance): 支持 macOS 毛玻璃窗口背景 2026-10-27 16:21:07 +08:00
陈奇 ea619555ac feat(plugin-agent): bind delegated MCP and model transport to exact turns 2026-10-27 07:43:52 +00:00
陈大猫 dcf642cf39 Merge pull request #4010 from binaricat/feat/nginx-highlighting-4005
feat(editor): add Nginx syntax highlighting
2026-10-27 15:22:51 +08:00
陈奇 d40b0d3c25 docs(plugins): prepare Settings visibility and current-main acceptance handoff 2026-10-27 07:08:50 +00:00
陈奇 714e2397a4 Merge reviewed Settings native-view visibility correction 2026-10-27 06:59:39 +00:00
陈奇 c4aedfa9ed Merge current main owner handoff and prompt fixes into isolated plugin acceptance 2026-10-27 06:59:39 +00:00
陈奇 ba4d3fc223 fix(plugin-agent): keep RPC cancellation separate from scope revocation 2026-10-27 06:56:03 +00:00
陈奇 1551fa8355 fix(plugins): suspend native views only for active log dialogs 2026-10-27 06:55:01 +00:00
陈奇 18377a1fd9 fix(plugin-agent): revoke failed owner scopes and bind Stop to its turn 2026-10-27 06:41:17 +00:00
陈大猫 1caf4a6479 feat(editor): add Nginx syntax highlighting 2026-10-27 14:40:29 +08:00
a0cf2b487c fix(mcp): recognize Oh My Zsh ys two-line idle prompts (#4008)
The two-line idle prompt check only accepted a classic user@host host
line, so Oh My Zsh ys (`# user @ host ...` then a bare $/#) never became
ready and terminal_execute sent nothing. Match that host line narrowly
and still reject a lone marker.

Fixes #4007

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 14:31:21 +08:00
陈奇 056164157f feat(plugin-agent): define source-bound global session authority and contract 2026-10-27 06:09:48 +00:00
陈大猫 e3e2dcfe02 fix(ai): keep dissolved workspace chat on its surviving owner (#4003)
Fixes #3998

Keep terminal-scoped AI chat on its surviving owner pane when dissolving a workspace; remint onto preferred only when the owner is gone.
2026-10-27 13:58:44 +08:00
陈奇 2433cf0130 docs(plugins): prepare isolated Ask routing QA and preserve 020 results 2026-10-27 05:46:36 +00:00
陈奇 1b21c41fa2 Merge reviewed managed-window Ask routing into isolated acceptance candidate 2026-10-27 05:31:36 +00:00
陈奇 89beeb9374 fix(plugins): route Ask to focused managed app windows 2026-10-27 05:06:38 +00:00
陈奇 02049102b6 docs(plugins): refresh final policy candidate provenance and native limits 2026-10-27 04:07:45 +00:00
陈奇 cfa13680e6 Merge authoritative policy disclosure correction preserving all assertions 2026-10-27 04:01:44 +00:00
陈奇 79a9bd6df1 fix(plugins): describe policy modes without mandatory consent copy 2026-10-27 03:51:51 +00:00
陈奇 7856c664c3 docs(plugins): record isolated policy and AI candidate with exact sample provenance 2026-10-27 03:43:01 +00:00
陈奇 7d8a354404 test(plugins): align package disclosure assertions with declared capability policy 2026-10-27 03:34:50 +00:00
陈奇 da4e80946d test(plugins): align package details with default Allow policy 2026-10-27 03:34:32 +00:00
陈奇 5d2ff1cb86 Merge reviewed AI startup and themed sample while preserving permission policy assertions 2026-10-27 03:25:05 +00:00
陈奇 9aa899059f Merge declared capability policy corrections into isolated acceptance candidate 2026-10-27 03:24:18 +00:00
陈奇 2eee561847 fix(plugins): bind scoped Ask receipts and revoke advanced activations 2026-10-27 03:17:53 +00:00
陈奇 d33c0193cc test(plugin-ai): drain Node module warnings before observing SDK errors 2026-10-27 02:33:17 +00:00
陈奇 643e71b17b feat(plugins): default declared capabilities to allow with scoped policy controls 2026-10-27 02:28:08 +00:00
陈奇 e325742abf fix(example-assistant): follow host theme and accent environment 2026-10-27 02:22:20 +00:00
陈奇 9ca70f3290 docs(plugin-ai): record cold-start evidence and native verification limits 2026-10-27 02:03:21 +00:00
陈奇 092438803b fix(plugin-ai): prepare host provider transport independently of the AI sidebar 2026-10-27 02:00:37 +00:00
陈奇 677aac33b1 docs(plugins): hand off verified correction candidate and rebuilt sample hashes 2026-10-26 20:15:22 +00:00
陈奇 24254ed737 Merge verified assistant selected-Note recovery into acceptance 2026-10-26 20:12:05 +00:00
陈奇 bbcf9c01c3 Merge verified terminal selection and inert draft correction into acceptance 2026-10-26 20:08:08 +00:00
陈奇 ba09461676 test(plugin-ai): verify repeated selected Note expiry recovery and update QA 2026-10-26 19:55:58 +00:00
陈奇 54b6cd401f docs(plugins): record completed AI review and active native holds 2026-10-26 19:55:55 +00:00
陈奇 9e7c9df58e fix(example-assistant): explicitly recover expired selected Note references 2026-10-26 19:53:18 +00:00
陈奇 824c3838e9 Merge published AI source-budget and model-session fixes provisionally 2026-10-26 19:50:06 +00:00
陈奇 35b65ef1d7 Merge verified settings gear focus correction into acceptance 2026-10-26 19:46:41 +00:00
陈奇 8e7c28fe1b fix(plugins): use effective pane ownership for terminal sharing 2026-10-26 19:35:41 +00:00
陈奇 07eb68c5e7 Merge current source and stream base into plugin AI corrections 2026-10-26 19:34:20 +00:00
陈奇 d81896b0d3 docs(plugin-ai): record bounded review repairs and replacement sample hash 2026-10-26 19:25:57 +00:00
陈奇 e1cb95ee87 fix(example-assistant): recover expired model sessions without inference retries 2026-10-26 19:23:22 +00:00
陈奇 ee5a4a4067 fix(plugin-ai): preserve source budgets across runtime replacement 2026-10-26 19:23:22 +00:00
陈奇 3e5d450a1e fix(plugins): publish selection targets after runtime mount and wake 2026-10-26 19:00:02 +00:00
陈奇 823db94cf3 fix(plugins): focus settings after direct gear navigation 2026-10-26 18:56:13 +00:00
陈奇 174c525cd4 test(plugins): validate unified acceptance entrypoints and record gates 2026-10-26 18:51:33 +00:00
陈奇 3cfc7db371 feat(plugins): complete selected terminal sharing and inert input drafts 2026-10-26 18:42:37 +00:00
陈奇 6cc7ab9501 merge: retain selected-terminal sharing WIP history 2026-10-26 18:42:37 +00:00
陈奇 4cbb34517a merge: reconcile current main with plugin integration 2026-10-26 18:42:37 +00:00
陈奇 2278681787 Merge verified settings and logging UI into acceptance candidate 2026-10-26 18:34:59 +00:00
陈奇 31d61e3040 Merge verified host and Vault actions into acceptance candidate 2026-10-26 18:34:25 +00:00
陈奇 52d4af2a86 Merge verified plugin byte stream integration onto acceptance main 2026-10-26 18:34:02 +00:00
陈奇 dc6e645fab fix(plugins): prebundle permission panel helper in Vite development 2026-10-26 18:18:52 +00:00
陈大猫 f0897c3c17 test(transfers): control TERM-SSHD alias arrival order (#3995) 2026-10-27 02:08:36 +08:00
陈大猫 8d826594d3 fix(ssh): support ChaCha20 in Electron crypto runtimes (#3992)
* fix(ssh): support ChaCha20 in Electron crypto runtimes

* test(ssh): verify packaged cipher transport across platforms

* test(ssh): isolate unused Electron UI APIs in packaged transport harness

* test(ssh): isolate packaged dependency resolution from the checkout
2026-10-27 01:59:11 +08:00
陈奇 37df67256e feat(examples): prepare fixed public Rfam acceptance candidate 2026-10-26 17:39:17 +00:00
陈奇 110448afb8 Merge late transport lifecycle and example checks into integration 2026-10-26 17:29:55 +00:00
a4999fdf1d fix: clear keychain ref when remembering KI password change (#3983)
* fix: clear keychain ref when remembering KI password change

Keyboard-interactive saves wrote the new host password but left
identityId attached. resolveHostAuth prefers the shared identity
password, so the next login kept the stale secret.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

resolveHostAuth prefers identity.username. Detaching the identity
without copying that username left the host's stale account next to
the new password.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: fill blank username only when detaching KI keychain identity

detachEffectiveHostIdentity copies the effective username only when the
host username is blank. Always replacing a set username went past that
helper.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: snapshot keychain username when remembering KI password

Clearing identityId left a different host username next to the new
password. Copy the identity username onto the host whenever it is
non-empty, matching the vault password detach.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ssh): exercise KI remember input and reconnect

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-27 01:11:50 +08:00
陈奇 41313293e1 fix(i18n): preserve Spanish parity for plugin settings 2026-10-26 17:11:19 +00:00
binaricat 730fa643ba fix(plugins): align host confirmation and refuse managed drafts 2026-10-26 17:07:41 +00:00
陈大猫 e907336ef5 fix(terminal): preserve active SSH input mode in attached popups (#3991) 2026-10-27 01:07:10 +08:00
陈奇 d16e21c848 docs(plugins): describe compact settings and manual update checks 2026-10-26 17:06:45 +00:00
陈奇 6a4fb178cd docs(plugins): provide reproducible samples and native integration handoff 2026-10-26 17:04:42 +00:00
陈奇 e6f44460ac fix(plugins): normalize local peers and settle cancelled stream credit 2026-10-26 17:03:15 +00:00
陈奇 e6dd68b209 feat(plugins): check published updates from bound source catalogs 2026-10-26 17:00:56 +00:00
陈奇 fffe5a9fca Merge connected plugin AI integration without reviewing its internals 2026-10-26 16:52:55 +00:00
陈奇 7189864c7c Merge source identity history into byte stream integration 2026-10-26 16:52:55 +00:00
陈奇 eb6772355e fix(plugins): preserve settings navigation and accessible dialog controls 2026-10-26 16:42:16 +00:00
binaricat 267bcbfb8b fix(plugins): reconcile shared permission descriptions 2026-10-26 16:37:40 +00:00
陈奇 17c48b4d79 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:36:39 +00:00
陈奇 8eefd9de96 feat(plugins): use compact settings rows and installed quick controls 2026-10-26 16:29:18 +00:00
陈奇 173ef5abbb docs(plugin-ai): retain red totals and record shared blocker repair 2026-10-26 16:25:16 +00:00
陈奇 0ba3ea6069 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:23:53 +00:00
陈奇 06dd46f94f docs(plugin-ai): record connected host QA and native handoff 2026-10-26 16:20:50 +00:00
binaricat 739cab2965 fix(plugins): lock inherited assistant example workspace 2026-10-26 16:18:09 +00:00
陈奇 42a9826603 fix(plugin-ai): yield native modal views to host context selection 2026-10-26 16:15:14 +00:00
binaricat cecfa3a47e fix(plugins): complete host action dialog locale parity 2026-10-26 16:11:33 +00:00
binaricat 0a590b4ebc feat(plugins): wire confirmed host and Vault actions 2026-10-26 16:07:10 +00:00
陈奇 da0c79ff0d fix(example-assistant): import URL in the package build tool 2026-10-26 16:03:01 +00:00
陈奇 a6e0cb8995 feat(plugin-ai): connect canonical trusted host IPC and app UI 2026-10-26 15:51:05 +00:00
binaricat 69c2cc8d1f Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into codex/plugin-host-vault 2026-10-26 15:41:43 +00:00
binaricat ccfe41f39a feat(plugins): prepare host action service and renderer execution 2026-10-26 15:41:42 +00:00
陈奇 bb2fc729b8 Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into feat/plugin-ai-context 2026-10-26 15:35:13 +00:00
陈奇 c29fc95531 docs(plugin-ai): define read-only context authority boundary 2026-10-26 15:30:52 +00:00
陈奇 b0386a5769 test(plugin-ai): isolate the timeout fixture from normal reply tests 2026-10-26 15:29:04 +00:00
binaricat d5cb8cfd3b feat(plugins): prepare safe host draft review boundaries 2026-10-26 15:24:10 +00:00
陈奇 dc45d03c7b test(plugin-ai): verify installed assistant and pending selection teardown 2026-10-26 15:21:35 +00:00
陈大猫 58c6a216ac feat: save SSH input compatibility per host (#3977)
* feat: save SSH input compatibility per host

* test: update extracted input harnesses for host mode

* test: provide required sync vault fields

* test: cover legacy cloud host migration after import

* fix: preserve non-SSH input compatibility behavior

* fix: keep Mosh and Eternal Terminal input policy unchanged

* fix: default new SSH link and importer drafts to standard input

* fix: retain connection input mode across renderer hibernation

* docs: distinguish input timing and native SSH evidence

* docs: record final native input compatibility acceptance
2026-10-26 23:16:06 +08:00
陈奇 2a9c310498 Merge frozen plugin sources and logging for native QA
Source parent: 4213d8e672
Logging parent: 9b853130ca

Ordinary merge with no manual conflicts or additional source edits.
2026-10-26 15:14:08 +00:00
陈奇 9290debe91 fix(plugin-ai): cancel active resources when source or host guards fail 2026-10-26 15:09:15 +00:00
陈奇 6cf931ac4d feat(plugin-ai): implement trusted renderer selection and model lifecycle 2026-10-26 15:07:38 +00:00
陈奇 9b853130ca fix(plugins): bind log provenance and enforce segment retention 2026-10-26 15:00:54 +00:00
陈奇 e3cc23bca8 feat(plugins): add opt-in local diagnostics and host audit logs 2026-10-26 14:26:41 +00:00
陈奇 3cdc97782e wip(plugins): save selected-terminal sharing authority and contract 2026-10-26 13:04:31 +00:00
陈大猫 eb2c4b1611 fix(terminal): bound printable ZMODEM prefix echo delay (#3966) 2026-10-26 19:16:34 +08:00
陈大猫 392a094297 fix: collapse timestamp gutter on alternate screen (#3975)
Alternate-screen programs still skip line timestamps; collapse the empty left gutter while alt buffer is active and restore on normal screen.

Fixes #3972
2026-10-26 18:13:38 +08:00
陈大猫 f300d583b2 fix(ssh): treat russh banners as single-channel (skip env) (#3974)
Fixes #3973

russh CHANNEL_FAILURE on wantReply=false env desyncs ssh2 FIFO;
add russh to SINGLE_CHANNEL_SOFTWARE_TOKENS so startSession skips env.
OpenSSH path unchanged.
2026-10-26 18:08:34 +08:00
陈大猫 70d58a0cad Merge pull request #3968 from binaricat/codex/fix-sudo-transfer-concurrency
fix(sftp): preserve ordinary sudo transfer concurrency
2026-10-26 17:45:30 +08:00
bincxz b92177ec42 fix(sftp): preserve ordinary sudo transfer concurrency 2026-10-26 17:28:34 +08:00
陈大猫 54481b6306 Merge pull request #3944 from lazzman/fix/single-channel-sftp-folder-upload
fix(sftp): 串行化单通道目录上传事务
2026-10-26 17:04:45 +08:00
bincxz 7e81bece9e test(sftp): wait for the second upload to reach the shared channel 2026-10-26 16:39:44 +08:00
陈大猫 04ee9afa7f fix(mcp): match two-line idle prompts against live PTY CR bytes (#3963)
Related #3958 / follow-up to #3961.

hasExpectedPromptSuffix must match live PTY \r\r\n against a cached two-line idle prompt (CR→LF form) so cancel/completion matching works after readiness.

No release / no tag.
2026-10-26 16:39:38 +08:00
陈大猫 efe5d4530d fix(win): kill leftover Netcatty.exe before NSIS install (#3946) (#3962)
NSIS customInit taskkill /F /IM Netcatty.exe (no /T) so orphaned external-MCP Netcatty.exe cannot lock $INSTDIR during in-app update.

Fixes #3946
2026-10-26 16:30:56 +08:00
陈大猫 32e75b6ace fix(mcp): recognize two-line user@host idle prompts (#3961)
Fixes #3958. extractTrailingIdlePrompt now accepts two-line user@host[:path] + bare $/ # idle prompts (including CR-normalized live PTY form) so MCP terminal_execute getReadyPrompt no longer times out.

No release/tag.
2026-10-26 16:26:29 +08:00
陈大猫 4a831f41c2 Merge pull request #3950 from binaricat/automation/nix-release-v1.1.90
Update Nix release metadata for v1.1.90
2026-10-26 15:09:55 +08:00
github-actions[bot] 141148ae0d Update Nix release metadata for v1.1.90 2026-10-26 05:54:33 +00:00
陈大猫 8d15a29bc6 fix(mcp): recognize Python env-prefixed idle POSIX prompts (#3948)
Idle SSH Bash prompts that start with a Python virtualenv, conda, or BT-panel name such as `(3.12.12) ` were not recognized. Recognition now strips one optional leading `(name) ` prefix, then tests the existing POSIX and Alpine prompt patterns.

Fixes #3947
2026-10-26 13:33:40 +08:00
lazzman fcb6723c3d fix(sftp): 串行化单通道目录上传事务 2026-10-26 12:02:48 +08:00
陈大猫 519c24cc4c fix(terminal): skip keyword highlight on ANSI-colored cells (#3941)
Keyword highlighting no longer overwrites cells whose PTY foreground is already 16/256/truecolor (Starship directory segments). Default-foreground matches still get the unix-path purple.

Fixes #3940
2026-10-26 11:45:28 +08:00
892 changed files with 57335 additions and 7147 deletions

No files matched your search

+9
View File
@@ -330,6 +330,9 @@ jobs:
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }} APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }} run: npm run ${{ matrix.pack_script }}
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Test tray panel layout - name: Test tray panel layout
run: npm run test:tray-panel-layout run: npm run test:tray-panel-layout
@@ -631,6 +634,9 @@ jobs:
done done
npm run pack:linux-x64 npm run pack:linux-x64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime - name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64 run: bash scripts/ensure-node-pty-linux.sh verify x64
@@ -765,6 +771,9 @@ jobs:
ELECTRON_BUILDER_PUBLISH: "never" ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64 run: npm run pack:linux-arm64
- name: Test packaged SSH cipher negotiation
run: node scripts/test-packaged-ssh-ciphers.cjs
- name: Verify packaged node-pty Linux runtime - name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64 run: bash scripts/ensure-node-pty-linux.sh verify arm64
+11
View File
@@ -53,11 +53,17 @@ jobs:
run: | run: |
npm run check:plugin-contract npm run check:plugin-contract
npm run test:plugin-runtime npm run test:plugin-runtime
npm run test:plugin-examples
node --test --import tsx components/settings/tabs/Plugin*.test.tsx node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test - name: Test
run: npm test run: npm test
- name: Test SSH cipher negotiation in Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Test browser first-link lifecycle - name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
@@ -144,5 +150,10 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
env:
NETCATTY_SSH_TEST_RUNTIME: electron
run: node --test electron/bridges/sshChaChaTransport.test.cjs
- name: Exercise two SSH password prompts in the desktop app - name: Exercise two SSH password prompts in the desktop app
run: node scripts/terminal-broadcast-ssh.live.cjs run: node scripts/terminal-broadcast-ssh.live.cjs
+3
View File
@@ -170,3 +170,6 @@ build_with_vs2022.bat
/resources/et/*/et.exe /resources/et/*/et.exe
/resources/et/*/et-*-dlls/ /resources/et/*/et-*-dlls/
/resources/et/*/*.dll /resources/et/*/*.dll
# Local plugin example staging and bundles
/examples/plugins/*/.package/
+5
View File
@@ -127,6 +127,11 @@ second private RPC shape when extending the host.
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and - `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
protocol-failure containment. Runtime identity is assigned by the host and is protocol-failure containment. Runtime identity is assigned by the host and is
never accepted from request parameters. never accepted from request parameters.
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
runtime; the host validates incoming trees, styles, resources and events before
rendering. Never reintroduce native child views, iframe UI, or plugin code in
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
lifecycle, limits and verification.
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do - App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
not add another independent quit interception path. not add another independent quit interception path.
+7
View File
@@ -12,7 +12,9 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip'; import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot'; import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost'; import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginPermissionHost } from './components/plugins/PluginPermissionHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost'; import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost'; import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog'; import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister'; import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -48,6 +50,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
* shell bags; AppSideEffects owns reactive effects + local UI publishing. * shell bags; AppSideEffects owns reactive effects + local UI publishing.
*/ */
function App() { function App() {
useEffect(() => {
performance.mark('netcatty:renderer:app-mounted');
}, []);
return ( return (
<AppLocalStateProvider> <AppLocalStateProvider>
<MonitorRuntimeHost /> <MonitorRuntimeHost />
@@ -87,11 +92,13 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot /> <ScriptAutomationRoot />
<ExternalMcpApprovalsHost /> <ExternalMcpApprovalsHost />
<PluginAuthenticationHost /> <PluginAuthenticationHost />
<PluginPermissionHost />
<PluginHostInteractionHost /> <PluginHostInteractionHost />
<MultilinePasteConfirmHost /> <MultilinePasteConfirmHost />
<VaultPublisher> <VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}> <SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App /> <App />
<PluginHostActionHost />
</SessionPublisher> </SessionPublisher>
</VaultPublisher> </VaultPublisher>
</TooltipProvider> </TooltipProvider>
+215 -1
View File
@@ -7,8 +7,9 @@ import {
handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveSubmitImpl,
handleTrayPanelConnectRequestImpl, handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts'; } from './app/AppHandlers.ts';
import { resolveHostAuth } from '../domain/sshAuth.ts';
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts'; import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
import type { Host } from '../types'; import type { Host, Identity } from '../types';
const baseHost: Host = { const baseHost: Host = {
id: 'host-1', id: 'host-1',
@@ -194,6 +195,219 @@ test('keyboard-interactive submit can save login password for the session host',
assert.equal(bridgeResponses.length, 1); assert.equal(bridgeResponses.length, 1);
}); });
test('keyboard-interactive submit detaches keychain identity when remembering a password change', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'root',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
// resolveHostAuth prefers identity.password, so the stale keychain secret
// shadows whatever is already stored on the host.
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'stale-keychain-password',
);
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate',
['rotated-password'],
'rotated-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'rotated-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, baseHost.username);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.password, 'stale-keychain-password');
assert.equal(
resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] }).password,
'rotated-password',
);
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit snapshots a different keychain username when remembering a password', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'deploy',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: 'root',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-rotate-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-rotate-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(hosts[0].username, 'deploy');
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'deploy');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'deploy');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit fills a blank host username from the keychain identity', async () => {
const sharedIdentity: Identity = {
id: 'id-1',
label: 'shared',
username: 'alice',
authMethod: 'password',
password: 'stale-keychain-password',
created: 0,
};
let hosts: Host[] = [{
...baseHost,
username: '',
identityId: 'id-1',
password: 'old-host-password',
savePassword: false,
}];
let queue = [{
requestId: 'ki-blank-user',
sessionId: 'session-1',
hostname: baseHost.hostname,
allowSavePassword: true,
}];
let identityUpdates = 0;
await handleKeyboardInteractiveSubmitImpl(
() => ({
hosts,
identities: [sharedIdentity],
keyboardInteractiveQueue: queue,
netcattyBridge: {
get: () => ({
respondKeyboardInteractive: () => ({ success: true }),
}),
},
sessions: [{
id: 'session-1',
hostId: baseHost.id,
hostname: baseHost.hostname,
}],
setKeyboardInteractiveQueue: (updater: (items: typeof queue) => typeof queue) => {
queue = updater(queue);
},
t: (key: string) => key,
toast: { error: () => {} },
updateHosts: (nextHosts: Host[]) => {
hosts = nextHosts;
},
updateIdentities: () => {
identityUpdates += 1;
},
}),
'ki-blank-user',
['new-password'],
'new-password',
);
assert.equal(hosts[0].identityId, '');
assert.equal(hosts[0].username, 'alice');
assert.equal(hosts[0].password, 'new-password');
assert.equal(hosts[0].savePassword, true);
assert.equal(identityUpdates, 0);
assert.equal(sharedIdentity.username, 'alice');
assert.equal(sharedIdentity.password, 'stale-keychain-password');
const resolved = resolveHostAuth({ host: hosts[0], keys: [], identities: [sharedIdentity] });
assert.equal(resolved.password, 'new-password');
assert.equal(resolved.username, 'alice');
assert.deepEqual(queue, []);
});
test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => { test('keyboard-interactive submit does not save secondary password when allowSavePassword is false', async () => {
let hosts: Host[] = [{ let hosts: Host[] = [{
...baseHost, ...baseHost,
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleCreateLocalTerminalImpl } from './AppHandlers';
type CreatedLocal = {
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
shellType?: string;
};
function createCtx(overrides?: {
localShell?: string;
localShellArgs?: string[];
discoveredShells?: Array<{ id: string; name: string; command: string; args?: string[]; icon?: string }>;
}) {
const created: CreatedLocal[] = [];
const logs: Array<{ hostLabel: string }> = [];
const localShell = overrides?.localShell ?? '';
const localShellArgs = overrides?.localShellArgs ?? [];
const discoveredShells = overrides?.discoveredShells ?? [];
const getCtx = () => ({
addConnectionLog: (entry: { hostLabel: string }) => {
logs.push(entry);
},
classifyLocalShellType: () => 'unknown',
createLocalTerminal: (options: CreatedLocal) => {
created.push(options);
return 'session-1';
},
discoveredShells,
resolveShellSetting: (
value: string,
shells: Array<{ id: string; command: string; args?: string[] }>,
customArgs?: string[],
) => {
if (!value) return null;
const match = shells.find((shell) => shell.id === value);
if (match) {
return { command: match.command, args: customArgs?.length ? customArgs : match.args };
}
return { command: value, args: customArgs?.length ? customArgs : undefined };
},
systemInfoRef: { current: { username: 'qi', hostname: 'box' } },
terminalSettings: { localShell, localShellArgs },
});
return { getCtx, created, logs };
}
test('create local terminal resolves Settings custom absolute path when no shell arg is passed', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
handleCreateLocalTerminalImpl(getCtx as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal ignores MouseEvent-like first args and still uses Settings shell (#4085)', () => {
const custom = String.raw`C:\Users\lybing\AppData\Local\Programs\nu\bin\nu.exe`;
const { getCtx, created } = createCtx({ localShell: custom });
// Vault header historically used onClick={handleCreateLocalTerminal}, so React
// passes the click event as the first argument.
const fakeClickEvent = { type: 'click', target: {}, preventDefault() {} };
handleCreateLocalTerminalImpl(getCtx as never, fakeClickEvent as never);
assert.equal(created.length, 1);
assert.equal(created[0].shell, custom);
});
test('create local terminal still honors an explicit shell object', () => {
const { getCtx, created } = createCtx({ localShell: 'pwsh' });
handleCreateLocalTerminalImpl(getCtx as never, {
command: String.raw`D:\portable\zsh\zsh.exe`,
args: ['-l'],
name: 'Portable Zsh',
icon: 'zsh',
});
assert.equal(created.length, 1);
assert.equal(created[0].shell, String.raw`D:\portable\zsh\zsh.exe`);
assert.deepEqual(created[0].shellArgs, ['-l']);
assert.equal(created[0].shellName, 'Portable Zsh');
assert.equal(created[0].shellIcon, 'zsh');
});
+35 -13
View File
@@ -16,6 +16,7 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
import { buildNumberShortcutTabTargets } from './tabShortcutTargets'; import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd'; import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore'; import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore'; import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>; type AppContextGetter = () => Record<string, any>;
@@ -264,7 +265,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
} }
} }
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
const isMonacoElement = const isMonacoElement =
target instanceof HTMLElement && target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox'); !!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
@@ -393,6 +394,7 @@ export async function handleKeyboardInteractiveSubmitImpl(
const { const {
hosts, hosts,
hostsRef, hostsRef,
identities,
keyboardInteractiveQueue, keyboardInteractiveQueue,
sessions, sessions,
setKeyboardInteractiveQueue, setKeyboardInteractiveQueue,
@@ -424,12 +426,27 @@ export async function handleKeyboardInteractiveSubmitImpl(
// Save password to host if requested - never for second-factor / EDR prompts // Save password to host if requested - never for second-factor / EDR prompts
// (allowSavePassword === false) so a secondary secret cannot overwrite the // (allowSavePassword === false) so a secondary secret cannot overwrite the
// host login password (#2150 / Codex review on #2151). // host login password (#2150 / Codex review on #2151).
// Detach a Keychain identity when one is attached. resolveHostAuth prefers
// identity.password and identity.username, so snapshot the identity username
// onto the host before clearing identityId (#3982). Same as
// applyVaultHostUpdate's password detach. Do not mutate the identity.
if (savePassword && host && request?.allowSavePassword !== false) { if (savePassword && host && request?.allowSavePassword !== false) {
updateHosts(latestHosts.map((h: Host) => h.id === host.id ? { updateHosts(latestHosts.map((h: Host) => {
...h, if (h.id !== host.id) return h;
password: savePassword, const identity = h.identityId
savePassword: true, ? identities?.find((item: { id?: string; username?: string }) => item.id === h.identityId)
} : h)); : undefined;
const detachedUsername = identity?.username?.trim();
return {
...h,
password: savePassword,
savePassword: true,
...(h.identityId ? {
identityId: '',
...(detachedUsername ? { username: detachedUsername } : {}),
} : {}),
};
}));
} }
// Remove from queue by requestId // Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId)); setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
@@ -1121,11 +1138,18 @@ export function handleCreateLocalTerminalImpl(
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx(); const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{ {
const { username, hostname } = systemInfoRef.current; const { username, hostname } = systemInfoRef.current;
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs); // Vault/header buttons historically wired `onClick={handleCreateLocalTerminal}`.
// React then passes the MouseEvent as the first arg; treat only real shell
// objects (string command) as explicit so Settings localShell still applies (#4085).
const explicitShell = shell && typeof shell.command === 'string' ? shell : undefined;
const resolved = explicitShell
?? resolveShellSetting(terminalSettings.localShell, discoveredShells, terminalSettings.localShellArgs);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe // Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined; const matchedShell = !explicitShell
const shellName = shell?.name ?? matchedShell?.name; ? discoveredShells.find(s => s.id === terminalSettings.localShell)
const shellIcon = shell?.icon ?? matchedShell?.icon; : undefined;
const shellName = explicitShell?.name ?? matchedShell?.name;
const shellIcon = explicitShell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({ const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent), shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command, shell: resolved?.command,
@@ -1255,16 +1279,14 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
} }
export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) { export function handleHostConnectWithProtocolCheckImpl(getCtx: AppContextGetter, host: Host) {
const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch } = getCtx(); const { handleConnectToHost, hasMultipleProtocols, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost } = getCtx();
{ {
if (hasMultipleProtocols(host)) { if (hasMultipleProtocols(host)) {
setProtocolSelectHost(resolveEffectiveHost(host)); setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false); setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else { } else {
handleConnectToHost(host); handleConnectToHost(host);
setIsQuickSwitcherOpen(false); setIsQuickSwitcherOpen(false);
setQuickSearch('');
} }
} }
} }
+11 -2
View File
@@ -139,12 +139,21 @@ test('ChromeHost builds from chrome settings + session/vault snapshots', () => {
assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/); assert.doesNotMatch(source, /\buseAppSettingsRuntime\s*\(/);
}); });
test('DialogsHost builds from local dialog state + selective vault snapshot', () => { test('DialogsHost builds from local dialog state without vault runtime hooks', () => {
const source = hostSources['DialogsHost.tsx']; const source = hostSources['DialogsHost.tsx'];
assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist'); assert.ok(source, 'application/app/hosts/DialogsHost.tsx must exist');
assert.match(source, /useVaultSnapshot|useVaultSnapshotField|getVaultSnapshot/);
assert.doesNotMatch(source, /\buseVaultState\s*\(/); assert.doesNotMatch(source, /\buseVaultState\s*\(/);
assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/); assert.doesNotMatch(source, /\buseAppVaultRuntime\s*\(/);
// Quick switcher search is owned by QuickSwitcherOverlay; republishing the
// dialogs bag per keystroke re-rendered the whole shell.
assert.doesNotMatch(source, /quickSearch|getHostSearchMatch/);
});
test('QuickSwitcherOverlay owns the quick switcher query', () => {
const overlay = readFileSync(join(here, 'QuickSwitcherOverlay.tsx'), 'utf8');
assert.match(overlay, /useState\(''\)/);
const store = readFileSync(join(here, 'appLocalUiStore.ts'), 'utf8');
assert.doesNotMatch(store, /quickSearch/);
}); });
test('AppSideEffects may use runtime hooks; App must not', () => { test('AppSideEffects may use runtime hooks; App must not', () => {
+28 -9
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { registerAppHandlers } from './appHandlersBridge'; import { registerAppHandlers, stabilizeAppHandlers, type StableAppHandlerCache } from './appHandlersBridge';
import { publishAppLocalUi } from './appLocalUiStore'; import { publishAppLocalUi } from './appLocalUiStore';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore'; import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { useAutoSync } from '../state/useAutoSync'; import { useAutoSync } from '../state/useAutoSync';
@@ -19,6 +19,8 @@ import {
} from '../state/connectionLogsStore'; } from '../state/connectionLogsStore';
import { getNotesSnapshot } from '../state/notesStore'; import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge'; import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
import { useWindowControls } from '../state/useWindowControls'; import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus'; import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore'; import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
@@ -59,6 +61,7 @@ import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace'; import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse'; import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification'; import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveCloseIntent } from '../state/resolveCloseIntent'; import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent'; import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose'; import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
@@ -133,7 +136,6 @@ export function AppSideEffects() {
| { mode: 'create' } | { mode: 'create' }
| null | null
>(null); >(null);
const [quickSearch, setQuickSearch] = useState('');
// Protocol selection dialog state for QuickSwitcher // Protocol selection dialog state for QuickSwitcher
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null); const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
// Navigation state for VaultView sections // Navigation state for VaultView sections
@@ -716,7 +718,7 @@ export function AppSideEffects() {
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]); }, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit // Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, t, updateHosts]); const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, hostsRef, identities, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, t, toast, updateHosts }), requestId, responses, savePassword); }, [identities, keyboardInteractiveQueue, sessions, hosts, t, updateHosts]);
// Handle keyboard-interactive cancel // Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]); const handleKeyboardInteractiveCancel = useCallback((requestId: string) => { return handleKeyboardInteractiveCancelImpl(() => ({ netcattyBridge, requestId, setKeyboardInteractiveQueue, t, toast }), requestId); }, [t]);
@@ -1156,6 +1158,8 @@ export function AppSideEffects() {
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null; const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]'); const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
const intent = resolveWindowCommandCloseIntent({ const intent = resolveWindowCommandCloseIntent({
activeTabId: activeTabStore.getActiveTabId(), activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)), editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
@@ -1524,6 +1528,19 @@ export function AppSideEffects() {
sessions, hosts, keys, identities, resolveEffectiveHost, sessions, hosts, keys, identities, resolveEffectiveHost,
}); });
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
}, [sessions]);
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
const live = terminalReconnectRegistry.inspect(sessionId);
return { ok: true, sessionId, status: session.status, waitingForInput: false,
reconnecting: false, reconnectAllowed: false, ...live };
}, [sessions]);
const closeSessionForVaultAgent = useCallback((sessionId: string) => { const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) { if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` }; return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1568,6 +1585,8 @@ export function AppSideEffects() {
openHost: openHostForVaultAgent, openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent, findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent, closeSession: closeSessionForVaultAgent,
reconnectSession: reconnectSessionForVaultAgent,
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId), getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
}); });
@@ -1944,7 +1963,7 @@ export function AppSideEffects() {
const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]); const hasMultipleProtocols = useCallback((host: Host) => { return hasMultipleProtocolsImpl(() => ({ host, resolveEffectiveHost }), host); }, [resolveEffectiveHost]);
// Handle host connect with protocol selection (used by QuickSwitcher) // Handle host connect with protocol selection (used by QuickSwitcher)
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost, setQuickSearch }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]); const handleHostConnectWithProtocolCheck = useCallback((host: Host) => { return handleHostConnectWithProtocolCheckImpl(() => ({ handleConnectToHost, hasMultipleProtocols, host, resolveEffectiveHost, setIsQuickSwitcherOpen, setProtocolSelectHost }), host); }, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
// Handle protocol selection from dialog // Handle protocol selection from dialog
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]); const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => { return handleProtocolSelectImpl(() => ({ handleConnectToHost, port, protocol, protocolSelectHost, setProtocolSelectHost }), protocol, port); }, [protocolSelectHost, handleConnectToHost]);
@@ -2012,8 +2031,11 @@ export function AppSideEffects() {
// Hosts assemble domain bags from stores + this flat glue. Do NOT register // Hosts assemble domain bags from stores + this flat glue. Do NOT register
// catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here. // catalog arrays or prepared vault/terminal/chrome/dialogs domain bags here.
// Handlers are registered behind stable per-key wrappers so session/host
// churn does not re-notify every Host (see stabilizeAppHandlers).
const appHandlerCacheRef = useRef<StableAppHandlerCache>(new Map());
useLayoutEffect(() => { useLayoutEffect(() => {
registerAppHandlers({ registerAppHandlers(stabilizeAppHandlers(appHandlerCacheRef.current, {
// Vault glue // Vault glue
handleAddKnownHost, handleAddKnownHost,
handleDeleteHost, handleDeleteHost,
@@ -2073,15 +2095,13 @@ export function AppSideEffects() {
setIsCreateWorkspaceOpen, setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen, setIsQuickSwitcherOpen,
setProtocolSelectHost, setProtocolSelectHost,
setQuickSearch,
handleCancelDeleteHost, handleCancelDeleteHost,
handleConfirmDeleteHost, handleConfirmDeleteHost,
}); }));
publishAppLocalUi({ publishAppLocalUi({
isQuickSwitcherOpen, isQuickSwitcherOpen,
isCreateWorkspaceOpen, isCreateWorkspaceOpen,
addToWorkspaceDialog, addToWorkspaceDialog,
quickSearch,
protocolSelectHost, protocolSelectHost,
navigateToSection, navigateToSection,
deepLinkHostDraft, deepLinkHostDraft,
@@ -2147,7 +2167,6 @@ export function AppSideEffects() {
isQuickSwitcherOpen, isQuickSwitcherOpen,
isCreateWorkspaceOpen, isCreateWorkspaceOpen,
addToWorkspaceDialog, addToWorkspaceDialog,
quickSearch,
protocolSelectHost, protocolSelectHost,
navigateToSection, navigateToSection,
deepLinkHostDraft, deepLinkHostDraft,
+55 -54
View File
@@ -26,6 +26,8 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer'; import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer'; import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost'; import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface'; import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore'; import { useConnectionLogsStore } from '../state/connectionLogsStore';
import { import {
@@ -44,6 +46,7 @@ import { getAvailablePaneMagnificationController } from '../../domain/paneMagnif
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore'; import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog'; import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor'; import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
import { QuickSwitcherOverlay } from './QuickSwitcherOverlay';
import { import {
appViewDomainsEqual, appViewDomainsEqual,
mergeAppViewDomains, mergeAppViewDomains,
@@ -51,9 +54,6 @@ import {
} from './appViewDomains'; } from './appViewDomains';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog')); const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() => const LazyCreateWorkspaceDialog = lazy(() =>
import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })), import('../../components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
); );
@@ -61,6 +61,9 @@ const LazyTextEditorTabView = lazy(() =>
import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })), import('../../components/editor/TextEditorTabView').then((m) => ({ default: m.TextEditorTabView })),
); );
// Module-level so TopTabs' memo comparator sees a stable callback.
const focusEditorWindowTab = (id: string) => { void focusEditorInWindow(id); };
const TextEditorTabFallback = ({ tabId }: { tabId: string }) => { const TextEditorTabFallback = ({ tabId }: { tabId: string }) => {
const isVisible = useIsEditorTabActive(tabId); const isVisible = useIsEditorTabActive(tabId);
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth(); const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
@@ -240,12 +243,10 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen, setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen, setIsQuickSwitcherOpen,
setProtocolSelectHost, setProtocolSelectHost,
setQuickSearch,
} = ctx; } = ctx;
const dismissTransientOverlays = useCallback(() => { const dismissTransientOverlays = useCallback(() => {
setIsQuickSwitcherOpen(false); setIsQuickSwitcherOpen(false);
setQuickSearch('');
setIsCreateWorkspaceOpen(false); setIsCreateWorkspaceOpen(false);
setProtocolSelectHost(null); setProtocolSelectHost(null);
setAddToWorkspaceDialog(null); setAddToWorkspaceDialog(null);
@@ -258,7 +259,6 @@ function AppViewInner({ domains }: AppViewProps) {
setIsCreateWorkspaceOpen, setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen, setIsQuickSwitcherOpen,
setProtocolSelectHost, setProtocolSelectHost,
setQuickSearch,
]); ]);
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays }); useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
@@ -275,7 +275,7 @@ function AppViewInner({ domains }: AppViewProps) {
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isComposeSendToAll, onToggleComposeSendTarget, isGlobalBroadcastEnabled, isGlobalComposeSendToAll, onToggleGlobalBroadcast, onToggleGlobalComposeSendTarget, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled, resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride, setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
@@ -504,6 +504,16 @@ function AppViewInner({ domains }: AppViewProps) {
await popOutEditorTab(id, host?.label); await popOutEditorTab(id, host?.label);
}, [hostById]); }, [hostById]);
// Stable editor-tab callbacks so memoized TextEditorTabView/TextEditorPane
// skip re-rendering when unrelated AppView state changes.
const requestCloseEditorTabFromView = useCallback(
(id: string) => handleRequestCloseEditorTabRef.current(id),
[handleRequestCloseEditorTabRef],
);
const popOutEditorTabFromView = useCallback((id: string) => {
void handlePopOutEditorTab(id);
}, [handlePopOutEditorTab]);
const handleSaveSessionRename = useCallback((name: string) => { const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return; if (!sessionRenameTarget) return;
if (!name.trim()) return; if (!name.trim()) return;
@@ -581,7 +591,7 @@ function AppViewInner({ domains }: AppViewProps) {
onClosePluginViewTab={closePluginViewTab} onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab} onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab} onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }} onFocusEditorWindowTab={focusEditorWindowTab}
hostById={hostById} hostById={hostById}
/> />
@@ -845,8 +855,8 @@ function AppViewInner({ domains }: AppViewProps) {
hotkeyScheme={hotkeyScheme} hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings} keyBindings={keyBindings}
hostById={hostById} hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)} onRequestClose={requestCloseEditorTabFromView}
onPopOut={(id) => { void handlePopOutEditorTab(id); }} onPopOut={popOutEditorTabFromView}
/> />
</Suspense> </Suspense>
</LazyLoadBoundary> </LazyLoadBoundary>
@@ -856,6 +866,8 @@ function AppViewInner({ domains }: AppViewProps) {
sessions={sessions} sessions={sessions}
workspaces={workspaces} workspaces={workspaces}
/> />
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
<PluginAgentHost sessions={sessions} />
</div> </div>
{/* Global "quick add / edit snippet" modal, triggered by the {/* Global "quick add / edit snippet" modal, triggered by the
@@ -933,50 +945,39 @@ function AppViewInner({ domains }: AppViewProps) {
)} )}
{isQuickSwitcherOpen && ( {isQuickSwitcherOpen && (
<LazyLoadBoundary name="Quick switcher" resetKey={quickSearch}> <QuickSwitcherOverlay
<Suspense fallback={null}> hosts={hosts}
<LazyQuickSwitcher isOpen={isQuickSwitcherOpen}
isOpen={isQuickSwitcherOpen} sessions={sessions}
query={quickSearch} workspaces={workspaces}
results={quickResults} showSftpTab={showSftpTab}
sessions={sessions} onSelect={handleHostConnectWithProtocolCheck}
workspaces={workspaces} onQuickConnect={openQuickConnectFromInput}
showSftpTab={showSftpTab} onEditHost={(host) => {
onQueryChange={setQuickSearch} setIsQuickSwitcherOpen(false);
onSelect={handleHostConnectWithProtocolCheck} handleEditHostFromOverlay(host);
onQuickConnect={openQuickConnectFromInput} }}
onEditHost={(host) => { onSelectTab={(tabId) => {
setIsQuickSwitcherOpen(false); setActiveTabId(tabId);
setQuickSearch(''); setIsQuickSwitcherOpen(false);
handleEditHostFromOverlay(host); }}
}} onCreateLocalTerminal={(shell) => {
onSelectTab={(tabId) => { handleCreateLocalTerminal(shell);
setActiveTabId(tabId); setIsQuickSwitcherOpen(false);
setIsQuickSwitcherOpen(false); }}
setQuickSearch(''); onCreateWorkspace={() => {
}} setIsQuickSwitcherOpen(false);
onCreateLocalTerminal={(shell) => { setAddToWorkspaceDialog({ mode: 'create' });
handleCreateLocalTerminal(shell); }}
setIsQuickSwitcherOpen(false); onClose={() => {
setQuickSearch(''); setIsQuickSwitcherOpen(false);
}} }}
onCreateWorkspace={() => { keyBindings={keyBindings}
setIsQuickSwitcherOpen(false); terminalSettings={terminalSettings}
setQuickSearch(''); paneMagnificationState={paneMagnificationState}
setAddToWorkspaceDialog({ mode: 'create' }); onMagnifyCurrentPane={handleMagnifyCurrentPane}
}} onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
onClose={() => { />
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
keyBindings={keyBindings}
terminalSettings={terminalSettings}
paneMagnificationState={paneMagnificationState}
onMagnifyCurrentPane={handleMagnifyCurrentPane}
onRestoreMagnifiedPane={handleRestoreMagnifiedPane}
/>
</Suspense>
</LazyLoadBoundary>
)} )}
<RenameDraftDialog <RenameDraftDialog
+48
View File
@@ -0,0 +1,48 @@
import React, { Suspense, lazy, useMemo, useState } from 'react';
import type { QuickSwitcherProps } from '../../components/QuickSwitcher';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { getHostSearchMatch } from '../../lib/searchMatcher';
import type { Host } from '../../types';
const LazyQuickSwitcher = lazy(() =>
import('../../components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
/** Hosts matching the quick switcher query, best match first. */
export function filterQuickSwitcherHosts(hosts: Host[], query: string): Host[] {
const term = query.trim();
if (!term) return hosts;
return hosts
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}
export type QuickSwitcherOverlayProps =
Omit<QuickSwitcherProps, 'query' | 'results' | 'onQueryChange'> & { hosts: Host[] };
/**
* Owns the quick switcher query. It used to live in AppSideEffects and travel
* through the app-local UI store, so every keystroke re-rendered the whole
* shell (AppSideEffects, every host island, AppShell, AppView and TopTabs).
* The overlay is only mounted while the switcher is open, so closing it resets
* the query just like the old `setQuickSearch('')` calls did.
*/
export function QuickSwitcherOverlay({ hosts, ...props }: QuickSwitcherOverlayProps) {
const [query, setQuery] = useState('');
const results = useMemo(() => filterQuickSwitcherHosts(hosts, query), [hosts, query]);
return (
<LazyLoadBoundary name="Quick switcher" resetKey={query}>
<Suspense fallback={null}>
<LazyQuickSwitcher {...props} query={query} results={results} onQueryChange={setQuery} />
</Suspense>
</LazyLoadBoundary>
);
}
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getAppHandlers,
registerAppHandlers,
stabilizeAppHandlers,
subscribeAppHandlers,
type StableAppHandlerCache,
} from './appHandlersBridge';
test('stabilized handlers keep their identity and call the latest implementation', () => {
const cache: StableAppHandlerCache = new Map();
const ref = { current: 1 };
const calls: string[] = [];
const first = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v1:${id}`),
toggleSidePanelRef: ref,
});
const second = stabilizeAppHandlers(cache, {
closeTabsBatch: (id: string) => calls.push(`v2:${id}`),
toggleSidePanelRef: ref,
});
assert.equal(second.closeTabsBatch, first.closeTabsBatch);
assert.equal(second.toggleSidePanelRef, ref, 'non-function values pass through');
(first.closeTabsBatch as (id: string) => void)('a');
assert.deepEqual(calls, ['v2:a']);
});
test('the bridge does not notify Hosts when only handler implementations change', () => {
const cache: StableAppHandlerCache = new Map();
let notified = 0;
const unsubscribe = subscribeAppHandlers(() => { notified += 1; });
try {
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v1' }));
const afterFirst = notified;
// A session status change recreated the callback (it closes over hosts).
registerAppHandlers(stabilizeAppHandlers(cache, { handleDeleteHost: () => 'v2' }));
assert.equal(notified, afterFirst);
assert.equal((getAppHandlers()!.handleDeleteHost as () => string)(), 'v2');
} finally {
unsubscribe();
registerAppHandlers(null);
}
});
+31
View File
@@ -51,6 +51,37 @@ class AppHandlersBridge {
const bridge = new AppHandlersBridge(); const bridge = new AppHandlersBridge();
export type StableAppHandlerCache = Map<string, { latest: unknown; stable: unknown }>;
/**
* Give every function-valued handler a per-key wrapper with a stable identity
* that always calls the latest implementation; non-functions (refs) pass
* through. Many glue callbacks close over sessions/workspaces/hosts, so their
* identities changed on every session status change and host update (connect
* bursts, last-connected times), and the bridge re-notified every Host island,
* rebuilding all shell bags and re-rendering AppView/TopTabs each time. The
* handlers are actions invoked from events, never read during render.
*/
export function stabilizeAppHandlers(cache: StableAppHandlerCache, handlers: AppHandlers): AppHandlers {
const stableHandlers: AppHandlers = {};
for (const [key, value] of Object.entries(handlers)) {
if (typeof value !== 'function') {
stableHandlers[key] = value;
continue;
}
let entry = cache.get(key);
if (!entry) {
const created: { latest: unknown; stable: unknown } = { latest: value, stable: null };
created.stable = (...args: unknown[]) => (created.latest as (...callArgs: unknown[]) => unknown)(...args);
cache.set(key, created);
entry = created;
}
entry.latest = value;
stableHandlers[key] = entry.stable;
}
return stableHandlers;
}
export function registerAppHandlers(handlers: AppHandlers | null): void { export function registerAppHandlers(handlers: AppHandlers | null): void {
bridge.set(handlers); bridge.set(handlers);
} }
-3
View File
@@ -24,7 +24,6 @@ export type AppLocalUiSnapshot = {
| { mode: 'append'; workspaceId: string } | { mode: 'append'; workspaceId: string }
| { mode: 'create' } | { mode: 'create' }
| null; | null;
quickSearch: string;
protocolSelectHost: Host | null; protocolSelectHost: Host | null;
navigateToSection: VaultSection | null; navigateToSection: VaultSection | null;
deepLinkHostDraft: Host | null; deepLinkHostDraft: Host | null;
@@ -43,7 +42,6 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
isQuickSwitcherOpen: false, isQuickSwitcherOpen: false,
isCreateWorkspaceOpen: false, isCreateWorkspaceOpen: false,
addToWorkspaceDialog: null, addToWorkspaceDialog: null,
quickSearch: '',
protocolSelectHost: null, protocolSelectHost: null,
navigateToSection: null, navigateToSection: null,
deepLinkHostDraft: null, deepLinkHostDraft: null,
@@ -76,7 +74,6 @@ class AppLocalUiStore {
prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen prev.isQuickSwitcherOpen === next.isQuickSwitcherOpen
&& prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen && prev.isCreateWorkspaceOpen === next.isCreateWorkspaceOpen
&& prev.addToWorkspaceDialog === next.addToWorkspaceDialog && prev.addToWorkspaceDialog === next.addToWorkspaceDialog
&& prev.quickSearch === next.quickSearch
&& prev.protocolSelectHost === next.protocolSelectHost && prev.protocolSelectHost === next.protocolSelectHost
&& prev.navigateToSection === next.navigateToSection && prev.navigateToSection === next.navigateToSection
&& prev.deepLinkHostDraft === next.deepLinkHostDraft && prev.deepLinkHostDraft === next.deepLinkHostDraft
+3 -29
View File
@@ -1,9 +1,6 @@
import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react'; import { useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
import { getHostSearchMatch } from '../../../lib/searchMatcher';
import type { Host } from '../../../types';
import { useEditorTabChromeList } from '../../state/editorTabStore'; import { useEditorTabChromeList } from '../../state/editorTabStore';
import { useVaultSnapshot } from '../../state/vaultSnapshotStore';
import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge'; import { getAppHandlers, subscribeAppHandlers } from '../appHandlersBridge';
import { import {
publishAppShellDomainSlice, publishAppShellDomainSlice,
@@ -11,16 +8,14 @@ import {
} from '../appShellPropsStore'; } from '../appShellPropsStore';
import { useAppLocalUiStore } from '../appLocalUiStore'; import { useAppLocalUiStore } from '../appLocalUiStore';
const EMPTY_HOST_RESULTS: Host[] = [];
/** /**
* Dialogs island: local dialog/queue state from `appLocalUiStore`, plus a * Dialogs island: local dialog/queue state from `appLocalUiStore`. The quick
* selective vault hosts subscription for quick-search results when open. * switcher owns its query and host results (QuickSwitcherOverlay), so typing
* there no longer republishes this bag.
* Assembles dialogs + overlays field-by-field — never spreads a prepared bag. * Assembles dialogs + overlays field-by-field — never spreads a prepared bag.
*/ */
export function DialogsHost() { export function DialogsHost() {
const local = useAppLocalUiStore(); const local = useAppLocalUiStore();
const vault = useVaultSnapshot();
const editorTabs = useEditorTabChromeList(); const editorTabs = useEditorTabChromeList();
const handlers = useSyncExternalStore( const handlers = useSyncExternalStore(
subscribeAppHandlers, subscribeAppHandlers,
@@ -28,22 +23,6 @@ export function DialogsHost() {
getAppHandlers, getAppHandlers,
); );
const quickResults = useMemo(() => {
if (!local.isQuickSwitcherOpen) return EMPTY_HOST_RESULTS;
const term = local.quickSearch.trim();
if (!term) return vault.hosts as Host[];
return (vault.hosts as Host[])
.map((host) => ({ host, match: getHostSearchMatch(term, host) }))
.filter((entry) => entry.match.matched)
.sort((left, right) => {
if (left.match.score !== right.match.score) {
return right.match.score - left.match.score;
}
return left.host.label.localeCompare(right.host.label);
})
.map((entry) => entry.host);
}, [local.isQuickSwitcherOpen, local.quickSearch, vault.hosts]);
const dialogsDomain = useMemo(() => { const dialogsDomain = useMemo(() => {
if (!handlers) return null; if (!handlers) return null;
return { return {
@@ -68,14 +47,11 @@ export function DialogsHost() {
passphraseQueue: local.passphraseQueue, passphraseQueue: local.passphraseQueue,
fidoPromptQueue: local.fidoPromptQueue, fidoPromptQueue: local.fidoPromptQueue,
protocolSelectHost: local.protocolSelectHost, protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict, resolveEmptyVaultConflict: handlers.resolveEmptyVaultConflict,
setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog, setAddToWorkspaceDialog: handlers.setAddToWorkspaceDialog,
setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen, setIsCreateWorkspaceOpen: handlers.setIsCreateWorkspaceOpen,
setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen, setIsQuickSwitcherOpen: handlers.setIsQuickSwitcherOpen,
setProtocolSelectHost: handlers.setProtocolSelectHost, setProtocolSelectHost: handlers.setProtocolSelectHost,
setQuickSearch: handlers.setQuickSearch,
}; };
}, [ }, [
editorTabs, editorTabs,
@@ -88,8 +64,6 @@ export function DialogsHost() {
local.passphraseQueue, local.passphraseQueue,
local.fidoPromptQueue, local.fidoPromptQueue,
local.protocolSelectHost, local.protocolSelectHost,
local.quickSearch,
quickResults,
]); ]);
const overlays = useMemo(() => { const overlays = useMemo(() => {
@@ -14,7 +14,22 @@ function loadComponent(path: string, name: string, bindings: Record<string, unkn
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), { const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
}); });
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType; // Extracting the function drops imports, so inject bare React hooks/APIs into the sandbox.
return vm.runInNewContext(`${code}; ${name}`, {
React,
useState: React.useState,
useEffect: React.useEffect,
useLayoutEffect: React.useLayoutEffect,
useMemo: React.useMemo,
useCallback: React.useCallback,
useRef: React.useRef,
useReducer: React.useReducer,
useContext: React.useContext,
memo: React.memo,
createElement: React.createElement,
Fragment: React.Fragment,
...bindings,
}) as React.ComponentType;
} }
test('monitor controls update their runtime without rendering unrelated app siblings', async () => { test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
@@ -32,6 +47,7 @@ test('monitor controls update their runtime without rendering unrelated app sibl
}; };
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime }); const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
const App = loadComponent('../../App.tsx', 'App', { const App = loadComponent('../../App.tsx', 'App', {
performance: { mark: () => {} },
useMonitorRuntime, useMonitorRuntime,
MonitorRuntimeHost, MonitorRuntimeHost,
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children, AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// Guards for what the startup bundle (index.html entry) may pull in. Each of
// these used to land in the entry chunk that every window parses at launch.
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (path: string) => readFileSync(join(root, path), 'utf8');
const staticImports = (source: string) =>
[...source.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => match[1]);
test('only the English dictionary is bundled with the startup entry', () => {
const imports = staticImports(read('application/i18n/messages.ts'));
assert.deepEqual(imports.filter((path) => path.startsWith('./locales/')), ['./locales/en']);
});
test('startup modules do not statically import the AI runtime or SDK providers', () => {
const runtimeModules = /harness\/globalAgentRuntime|plugin\/trustedHostRuntime|plugin\/configuredModel|pluginAgent\/trustedAgentRuntime|sdk\/providers/;
for (const path of [
'application/state/aiStateSnapshots.ts',
'application/state/usePluginAIContextHost.ts',
'application/state/usePluginAgentHost.ts',
]) {
const offending = staticImports(read(path)).filter((specifier) => runtimeModules.test(specifier));
assert.deepEqual(offending, [], `${path} must load the AI runtime lazily`);
}
});
test("Vite's preload helper stays out of the vendor-ai chunk", () => {
// The entry imports the helper for its dynamic imports; if it is grouped
// with the AI SDK, the whole vendor-ai chunk is preloaded at startup.
assert.match(read('vite.config.ts'), /vite\/preload-helper'\)\) return 'preload-helper'/);
});
test('the QR decoder is loaded on demand', () => {
assert.ok(!staticImports(read('components/HostTotpFields.tsx')).includes('jsqr'));
});
+20 -13
View File
@@ -293,7 +293,10 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
terminalSettings, terminalSettings,
}); });
// Sync tray menu data + handle tray actions // Sync tray menu data + handle tray actions. Hosts are only re-sent when the
// host list changed: session status updates (connect bursts) used to resend
// and re-serialize every host and rebuild the macOS Dock menu each time.
const lastTrayHostsRef = useRef<unknown>(null);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
@@ -316,17 +319,21 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
}; };
}); });
const hostsForSystemMenu = hosts const hostsChanged = lastTrayHostsRef.current !== hosts;
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0) lastTrayHostsRef.current = hosts;
.map((host: any) => ({ const hostsForSystemMenu = hostsChanged
id: host.id, ? hosts
label: host.label, .filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
hostname: host.hostname, .map((host: any) => ({
group: host.group, id: host.id,
pinned: host.pinned, label: host.label,
lastConnectedAt: host.lastConnectedAt, hostname: host.hostname,
protocol: host.protocol, group: host.group,
})); pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}))
: undefined;
void bridge.updateTrayMenuData({ void bridge.updateTrayMenuData({
sessions: sessionsForTray, sessions: sessionsForTray,
@@ -334,7 +341,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
...rule, ...rule,
canStop: hasRuntimeTunnel(rule.id), canStop: hasRuntimeTunnel(rule.id),
})), })),
hosts: hostsForSystemMenu, ...(hostsForSystemMenu ? { hosts: hostsForSystemMenu } : {}),
}); });
}, 250); }, 250);
@@ -21,13 +21,20 @@ Object.defineProperty(globalThis, 'localStorage', {
}); });
const { const {
applyLegacySyncPayload,
convergentConflictAddressKey,
createConvergentSyncStateFromPayload,
dotKey,
mergeConvergentSyncStates,
planConvergentSyncMigration, planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
stripConvergentSyncEnvelope, stripConvergentSyncEnvelope,
} = await import('../domain/convergentSync/index.ts'); } = await import('../domain/convergentSync/index.ts');
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts'); const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
const { const {
initializePreparedConvergentMigration, initializePreparedConvergentMigration,
prepareConvergentSyncMigration, prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
} = await import('./convergentSyncMigration.ts'); } = await import('./convergentSyncMigration.ts');
function payload(): SyncPayload { function payload(): SyncPayload {
@@ -44,6 +51,42 @@ test.beforeEach(() => {
localStorageValues.clear(); localStorageValues.clear();
}); });
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const state = mergeConvergentSyncStates(
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
);
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
}), state);
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
const conflict = plan.preview.conflicts[0];
const addressKey = convergentConflictAddressKey(conflict.address);
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
const resolved = resolvePreparedConvergentMigrationConflict(
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
);
assert.equal(resolved.plan.preview.canInitialize, true);
assert.equal(resolved.plan.preview.conflictCount, 0);
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
assert.ok(resolved.plan.state?.vector.resolver);
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
assert.equal(prepared.plan.preview.conflictCount, 1);
assert.equal(prepared.plan.payload, null);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, 'missing', dotKey(selected.dot), manager,
), /conflict no longer exists/);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, addressKey, 'missing', manager,
), /candidate no longer exists/);
});
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => { test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
const remotePayload: SyncPayload = { const remotePayload: SyncPayload = {
...payload(), ...payload(),
+22
View File
@@ -6,8 +6,11 @@ import type {
} from '../domain/sync'; } from '../domain/sync';
import { import {
cloudSyncPayloadsEqual, cloudSyncPayloadsEqual,
convergentConflictAddressKey,
materializeSyncPayloadFromConvergentState, materializeSyncPayloadFromConvergentState,
planConvergentSyncMigration, planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
resolveConvergentFieldConflict,
stripConvergentSyncEnvelope, stripConvergentSyncEnvelope,
validateConvergentSyncPayload, validateConvergentSyncPayload,
type ConvergentMigrationPlan, type ConvergentMigrationPlan,
@@ -25,6 +28,25 @@ export interface PreparedConvergentMigration {
localSnapshot: SyncPayload; localSnapshot: SyncPayload;
} }
export function resolvePreparedConvergentMigrationConflict(
prepared: PreparedConvergentMigration,
addressKey: string,
candidateDot: string,
manager: CloudSyncManager = getCloudSyncManager(),
now = Date.now(),
): PreparedConvergentMigration {
const conflict = prepared.plan.preview.conflicts.find(
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
);
if (!prepared.plan.state || !conflict) {
throw new Error('The convergent migration conflict no longer exists');
}
const state = resolveConvergentFieldConflict(
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
);
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
}
interface LegacyProviderBaselineSeed { interface LegacyProviderBaselineSeed {
provider: CloudProvider; provider: CloudProvider;
remoteVersion: number; remoteVersion: number;
+46 -12
View File
@@ -1,6 +1,13 @@
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n'; import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { MESSAGES_BY_LOCALE } from './messages'; import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
type Messages,
} from './messages';
type InterpolationValues = Record<string, string | number | boolean | null | undefined>; type InterpolationValues = Record<string, string | number | boolean | null | undefined>;
@@ -29,32 +36,59 @@ const interpolate = (template: string, values?: InterpolationValues): string =>
.replace(/\{(\w+)\}/g, replaceSingleBraceToken); .replace(/\{(\w+)\}/g, replaceSingleBraceToken);
}; };
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => { type LocaleDictionaries = {
const direct = MESSAGES_BY_LOCALE[resolvedLocale]?.[key]; direct?: Messages;
if (direct) return direct; base?: Messages;
const base = resolvedLocale.split('-')[0]; fallback?: Messages;
const baseKey = Object.keys(MESSAGES_BY_LOCALE).find((k) => k === base || k.startsWith(`${base}-`));
const baseHit = baseKey ? MESSAGES_BY_LOCALE[baseKey]?.[key] : undefined;
if (baseHit) return baseHit;
return MESSAGES_BY_LOCALE[DEFAULT_UI_LOCALE]?.[key];
}; };
/**
* Dictionaries consulted for `resolvedLocale`: exact locale, same-base sibling
* (zh-TW -> zh-CN), then English. `_loadedVersion` is only a cache key so a
* dictionary that finishes loading yields a fresh snapshot.
*/
const snapshotLocaleDictionaries = (resolvedLocale: string, _loadedVersion: number): LocaleDictionaries => {
const baseKey = getBaseFallbackLocale(resolvedLocale);
return {
direct: getLoadedLocaleMessages(resolvedLocale),
base: baseKey ? getLoadedLocaleMessages(baseKey) : undefined,
fallback: getLoadedLocaleMessages(DEFAULT_UI_LOCALE),
};
};
const resolveMessage = (dictionaries: LocaleDictionaries, key: string): string | undefined =>
dictionaries.direct?.[key] || dictionaries.base?.[key] || dictionaries.fallback?.[key];
export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({ export const I18nProvider: React.FC<{ locale: string; children: React.ReactNode }> = ({
locale, locale,
children, children,
}) => { }) => {
const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE); const resolvedLocale = resolveSupportedLocale(locale || DEFAULT_UI_LOCALE);
// Non-English dictionaries load on demand (index.tsx preloads the saved one
// before the first render); re-render consumers once a switch finishes.
const messagesVersion = useSyncExternalStore(
subscribeLocaleMessages,
getLocaleMessagesVersion,
getLocaleMessagesVersion,
);
useEffect(() => {
void loadLocaleMessages(resolvedLocale).catch(() => {});
}, [resolvedLocale]);
const dictionaries = useMemo(
() => snapshotLocaleDictionaries(resolvedLocale, messagesVersion),
[resolvedLocale, messagesVersion],
);
const value = useMemo<I18nContextValue>(() => { const value = useMemo<I18nContextValue>(() => {
return { return {
locale, locale,
resolvedLocale, resolvedLocale,
t: (key, values) => { t: (key, values) => {
const msg = resolveMessage(resolvedLocale, key) ?? key; const msg = resolveMessage(dictionaries, key) ?? key;
return interpolate(msg, values); return interpolate(msg, values);
}, },
}; };
}, [locale, resolvedLocale]); }, [dictionaries, locale, resolvedLocale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}; };
+52
View File
@@ -99,6 +99,7 @@ export const enAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.', 'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096', 'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default', 'ai.providers.advancedParams.default': 'Provider default',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
// AI Codex // AI Codex
'ai.codex': 'Codex', 'ai.codex': 'Codex',
@@ -525,6 +526,8 @@ export const enAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Always', 'ai.chat.alwaysAllow': 'Always',
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools', 'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context', 'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
'ai.chat.reject': 'Reject', 'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool', 'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target', 'ai.chat.targetLabel': 'Target',
@@ -587,6 +590,28 @@ export const enAiMessages: Messages = {
'ai.chat.recent': 'Recent', 'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All', 'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled', 'ai.chat.untitled': 'Untitled',
'ai.chat.renameChat': 'Rename chat',
'ai.chat.renameUsage': 'Usage: /rename <new title>',
'ai.chat.renameNoActive': 'No active chat to rename.',
'ai.chat.renameDone': 'Chat renamed to "{title}".',
'ai.chat.exportSessions': 'Export AI chats',
'ai.chat.importSessions': 'Import AI chats',
'ai.chat.exportDone': 'AI chats exported to {path}.',
'ai.chat.exportDoneNoPath': 'AI chats exported.',
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
'ai.chat.importDone': 'Imported {n} AI chat(s).',
'ai.chat.importNone': 'No AI chats found in the selected file.',
'ai.chat.chatsHeader': 'Chats visible in this panel:',
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Dissociated',
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
'ai.chat.justNow': 'Just now', 'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago', 'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago', 'ai.chat.hoursAgo': '{n}h ago',
@@ -636,6 +661,10 @@ export const enAiMessages: Messages = {
'ai.chatShortcuts.title': 'Chat Shortcuts', 'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text', 'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.', 'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
'ai.chatHistory.title': 'Chat History',
'ai.chatHistory.messageCap': 'Messages kept per chat',
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
'ai.chatHistory.messageCap.unit': 'messages',
// AI Error // AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.', 'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
@@ -795,4 +824,27 @@ export const enAiMessages: Messages = {
'zmodem.overwrite.skip': 'Skip', 'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel', 'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default', 'settings.shortcuts.resetToDefault': 'Reset to default',
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
"ai.toolSettings.privacyOn": "Text redaction enabled",
"ai.toolSettings.privacyOff": "Text redaction disabled",
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
"ai.toolSettings.configurePrivacy": "Configure privacy",
"ai.toolSettings.unsaved": "Unsaved changes",
"ai.toolSettings.viewSkills": "View CLI guidance",
"ai.toolSettings.viewPrompt": "View setup prompt",
"ai.toolSettings.behavior": "MCP behavior",
"ai.toolSettings.configureBehavior": "Configure behavior",
"ai.toolSettings.configureClients": "Configure clients",
"ai.toolSettings.httpOn": "HTTP enabled",
"ai.toolSettings.httpOff": "HTTP disabled",
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configure HTTP",
"ai.toolSettings.hideSecret": "Hide secret",
"ai.toolSettings.showSecret": "Show secret",
"ai.toolSettings.manualSetup": "Manual setup",
"ai.toolSettings.format": "Format",
"ai.toolSettings.connectionDetails": "Connection details",
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
"ai.toolSettings.retry": "Retry",
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
}; };
+73 -7
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const enCoreMessages: Messages = { export const enCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
"settings.plugins.landing.updateAvailable": "Version {version} available",
"settings.plugins.landing.loading": "Loading installed plugins…",
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
"settings.plugins.landing.options": "Plugin options",
"settings.plugins.landing.manager": "Plugin manager",
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
"settings.plugins.landing.browse": "Browse",
"settings.plugins.landing.refresh": "Refresh installed plugins",
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
"settings.plugins.landing.search": "Search installed plugins",
"settings.plugins.landing.settingsFor": "Settings for {name}",
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
"settings.plugins.landing.sourceUnknown": "Source unknown",
"settings.plugins.landing.status.running": "Running",
"settings.plugins.landing.status.starting": "Starting",
"settings.plugins.landing.status.stopping": "Stopping",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "Quarantined",
"settings.plugins.landing.status.unknown": "Status unknown",
"settings.plugins.landing.status.idle": "Enabled, idle",
// Common // Common
'common.save': 'Save', 'common.save': 'Save',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
@@ -135,7 +161,12 @@ export const enCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Clear temp files', 'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...', 'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.', 'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.', 'settings.system.tempDirChoose': 'Choose folder',
'settings.system.tempDirManual': 'Enter an absolute folder path',
'settings.system.tempDirApply': 'Apply',
'settings.system.tempDirReset': 'Reset to default',
'settings.system.tempDirError': 'Unable to use this folder.',
'settings.system.tempDirectoryHint': 'Netcatty creates a Netcatty temp subfolder in the chosen folder for SFTP staging and external editing. Changing the folder during a transfer may affect that transfer.',
'settings.system.credentials.title': 'Credential Protection', 'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status', 'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...', 'settings.system.credentials.checking': 'Checking...',
@@ -556,6 +587,8 @@ Highlight the focused split pane:
'settings.appearance.uiFont.desc': 'Choose the font for the application interface', 'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity', 'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.', 'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
'settings.appearance.windowBackgroundBlur': 'Frosted glass background',
'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.',
'settings.appearance.appIcon': 'App Icon', 'settings.appearance.appIcon': 'App Icon',
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.', 'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
'settings.appearance.appIcon.original': 'Original', 'settings.appearance.appIcon.original': 'Original',
@@ -1236,6 +1269,8 @@ Highlight the focused split pane:
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}', 'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins', 'settings.tab.plugins': 'Plugins',
"settings.plugins.manager.installed": "Installed plugins", "settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.author.official": "Official maintainer",
"settings.plugins.author.officialMeaning": "Maintained by the Netcatty project. This is not a safety certification.",
"settings.plugins.manager.import": "Import .ncpkg", "settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.", "settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.", "settings.plugins.manager.empty": "Import a local plugin package to get started.",
@@ -1262,21 +1297,33 @@ Highlight the focused split pane:
"plugins.interaction.share": "Share with plugin", "plugins.interaction.share": "Share with plugin",
"plugins.interaction.copy": "Copy to clipboard", "plugins.interaction.copy": "Copy to clipboard",
"plugins.interaction.failed": "This request expired or could not be completed.", "plugins.interaction.failed": "This request expired or could not be completed.",
"plugins.command.failed": "The plugin command could not be completed. Check its required selection and permissions.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Matches the current activation", "settings.plugins.permissions.current": "Matches the current activation",
"settings.plugins.permissions.inactive": "Inactive for the current activation", "settings.plugins.permissions.inactive": "Inactive for the current activation",
"settings.plugins.permissions.title": "Saved grants", "settings.plugins.permissions.note": "Declared capabilities default to Allow. Ask requests a scoped decision; Deny blocks access immediately. Target selection and draft editing still use the host UI.",
"settings.plugins.permissions.allow": "Allow",
"settings.plugins.permissions.ask": "Ask",
"settings.plugins.permissions.deny": "Deny",
"settings.plugins.permissions.title": "Permissions",
"settings.plugins.permissions.refresh": "Refresh", "settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...", "settings.plugins.permissions.loading": "Loading permissions...",
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.", "settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked", "settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits", "settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes", "settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)", "settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants", "settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.", "settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Your Allow / Ask / Deny settings are retained.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable", "settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions", "settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.", "settings.plugins.manager.permissionNote": "Declared supported capabilities default to Allow. Change each capability to Ask or Deny in the permission controls. Host validation and resource scope always apply.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.", "settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required", "settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional", "settings.plugins.manager.optional": "Optional",
@@ -1319,7 +1366,7 @@ Highlight the focused split pane:
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.", "settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.", "settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.", "settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.", "settings.plugins.manager.sensitive": "Sensitive access: this capability may expose private data. Its mode can be changed in permission settings.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.", "settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.", "settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry", "settings.plugins.market.retry": "Retry",
@@ -1328,11 +1375,14 @@ Highlight the focused split pane:
"settings.plugins.market.search": "Search name, author or description", "settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.", "settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions", "settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.", "settings.plugins.manager.unbounded": "No narrower resource scope is declared. Host validation and the selected permission mode still apply.",
"settings.plugins.openView": "Open", "settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager", "settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:", "settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.", "settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Official",
"settings.plugins.sources.short.thirdparty": "Third-party",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace", "settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace", "settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources", "settings.plugins.sources.manage": "Manage sources",
@@ -1351,6 +1401,7 @@ Highlight the focused split pane:
"settings.plugins.sources.local": "Local import", "settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources", "settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins", "settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Plugin settings",
"settings.plugins.description": "Manage your plugins and explore the catalog.", "settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…', 'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.', 'settings.plugins.empty': 'No enabled plugin contributes settings.',
@@ -1380,5 +1431,20 @@ Highlight the focused split pane:
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.', 'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.', 'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue', 'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Only the selected hosts\' saved labels, addresses, usernames, protocols, groups and tags are shared. Group selection includes only the hosts selected now. Later edit or connection requests follow this plugin\'s permission settings.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
'plugins.hostActions.fieldDiff': 'Changes to save',
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
}; };
+4
View File
@@ -1,4 +1,8 @@
export const enMonitorMessages = { export const enMonitorMessages = {
"monitor.systemDisks": "System partitions ({count})",
"monitor.systemDisksAlert": "System partition usage exceeds 80%; expanded automatically.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "I/O of the block device backing this mount. Mounts sharing a device show the same rates. Unavailable until two valid samples or when no block device can be identified; not supported on macOS.",
"monitor.diskIo": "Disk I/O", "monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read", "monitor.diskRead": "Read",
"monitor.diskWrite": "Write", "monitor.diskWrite": "Write",
+20
View File
@@ -1,6 +1,24 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const enTerminalMessages: Messages = { export const enTerminalMessages: Messages = {
'terminal.directory.open': 'Current directory: {path}. Change directory',
'terminal.directory.unknown': 'Directory unknown',
'terminal.directory.search': 'Search child directories...',
'terminal.directory.directories': 'Directories',
'terminal.directory.parent': '.. (Parent directory)',
'terminal.directory.loading': 'Reading directories...',
'terminal.directory.empty': 'No child directories',
'terminal.directory.noMatches': 'No matching directories',
'terminal.directory.hidden': 'Show hidden directories',
'terminal.directory.refresh': 'Refresh',
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
'terminal.directory.busy': 'Wait for an empty shell prompt.',
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
'terminal.toolbar.openSessionLog': 'Open session log', 'terminal.toolbar.openSessionLog': 'Open session log',
'terminal.toolbar.sessionLogActions': 'Session log actions', 'terminal.toolbar.sessionLogActions': 'Session log actions',
'terminal.toolbar.sessionLog.menu': 'Session log', 'terminal.toolbar.sessionLog.menu': 'Session log',
@@ -460,6 +478,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.', 'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
'cloudSync.localBackups.title': 'Local Backup History', 'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.', 'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.folderWarning': 'This folder alone is not a complete, portable backup. Deleting or wiping the app profile (userData) may make these restore points unreadable.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention', 'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.', 'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups', 'cloudSync.localBackups.maxCount': 'Max backups',
@@ -474,6 +493,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.', 'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed', 'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.', 'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'cloudSync.localBackups.decryptFailed': 'The current app profile cannot decrypt this backup. The encryption key may be missing, the profile may have changed, or the backup may be damaged.',
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}', 'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?', 'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.', 'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
+3
View File
@@ -975,6 +975,9 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.', 'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout', 'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.', 'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior', 'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default', 'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts', 'hostDetails.jumpHosts': 'Proxy via Hosts',
+52
View File
@@ -90,6 +90,7 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.', 'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096', 'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor', 'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
'ai.providers.headers': 'Cabeceras HTTP personalizadas', 'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.', 'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera', 'ai.providers.headers.name': 'Nombre de la cabecera',
@@ -525,6 +526,8 @@ export const esAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Siempre', 'ai.chat.alwaysAllow': 'Siempre',
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso', 'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación', 'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
'ai.chat.reject': 'Rechazar', 'ai.chat.reject': 'Rechazar',
'ai.chat.toolLabel': 'Herramienta', 'ai.chat.toolLabel': 'Herramienta',
'ai.chat.targetLabel': 'Destino', 'ai.chat.targetLabel': 'Destino',
@@ -587,6 +590,28 @@ export const esAiMessages: Messages = {
'ai.chat.recent': 'Recientes', 'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo', 'ai.chat.viewAll': 'Ver todo',
'ai.chat.untitled': 'Sin título', 'ai.chat.untitled': 'Sin título',
'ai.chat.renameChat': 'Renombrar chat',
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
'ai.chat.exportSessions': 'Exportar chats de IA',
'ai.chat.importSessions': 'Importar chats de IA',
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Desasociado',
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
'ai.chat.justNow': 'Ahora mismo', 'ai.chat.justNow': 'Ahora mismo',
'ai.chat.minutesAgo': 'hace {n} min', 'ai.chat.minutesAgo': 'hace {n} min',
'ai.chat.hoursAgo': 'hace {n} h', 'ai.chat.hoursAgo': 'hace {n} h',
@@ -636,6 +661,10 @@ export const esAiMessages: Messages = {
'ai.chatShortcuts.title': 'Atajos de chat', 'ai.chatShortcuts.title': 'Atajos de chat',
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal', 'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.', 'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
'ai.chatHistory.title': 'Historial de chat',
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
'ai.chatHistory.messageCap.unit': 'mensajes',
// AI Error // AI Error
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.', 'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
@@ -795,4 +824,27 @@ export const esAiMessages: Messages = {
'zmodem.overwrite.skip': 'Omitir', 'zmodem.overwrite.skip': 'Omitir',
'zmodem.overwrite.cancel': 'Cancelar', 'zmodem.overwrite.cancel': 'Cancelar',
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado', 'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
"ai.toolSettings.unsaved": "Cambios sin guardar",
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
"ai.toolSettings.behavior": "Comportamiento de MCP",
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
"ai.toolSettings.configureClients": "Configurar clientes",
"ai.toolSettings.httpOn": "HTTP activado",
"ai.toolSettings.httpOff": "HTTP desactivado",
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configurar HTTP",
"ai.toolSettings.hideSecret": "Ocultar secreto",
"ai.toolSettings.showSecret": "Mostrar secreto",
"ai.toolSettings.manualSetup": "Configuración manual",
"ai.toolSettings.format": "Formato",
"ai.toolSettings.connectionDetails": "Detalles de conexión",
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
"ai.toolSettings.retry": "Reintentar",
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
}; };
+71 -5
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const esCoreMessages: Messages = { export const esCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
"settings.plugins.landing.loading": "Cargando plugins instalados…",
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
"settings.plugins.landing.options": "Opciones de plugins",
"settings.plugins.landing.manager": "Gestor de plugins",
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
"settings.plugins.landing.browse": "Explorar",
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
"settings.plugins.landing.search": "Buscar plugins instalados",
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
"settings.plugins.landing.status.running": "En ejecución",
"settings.plugins.landing.status.starting": "Iniciando",
"settings.plugins.landing.status.stopping": "Deteniendo",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "En cuarentena",
"settings.plugins.landing.status.unknown": "Estado desconocido",
"settings.plugins.landing.status.idle": "Activado, en espera",
// Common // Common
'common.save': 'Guardar', 'common.save': 'Guardar',
'common.cancel': 'Cancelar', 'common.cancel': 'Cancelar',
@@ -135,7 +161,12 @@ export const esCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Limpiar archivos temporales', 'settings.system.clearTempFiles': 'Limpiar archivos temporales',
'settings.system.clearing': 'Limpiando...', 'settings.system.clearing': 'Limpiando...',
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.', 'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
'settings.system.tempDirectoryHint': 'Los archivos temporales se crean al abrir archivos remotos con aplicaciones externas. Se limpian automáticamente cuando las sesiones SFTP se cierran.', 'settings.system.tempDirChoose': 'Elegir carpeta',
'settings.system.tempDirManual': 'Introduce una ruta de carpeta absoluta',
'settings.system.tempDirApply': 'Aplicar',
'settings.system.tempDirReset': 'Restablecer predeterminado',
'settings.system.tempDirError': 'No se puede usar esta carpeta.',
'settings.system.tempDirectoryHint': 'Se usa para el almacenamiento temporal de transferencias SFTP y archivos abiertos con aplicaciones externas. Cambiar la carpeta durante una transferencia puede afectar esa transferencia.',
'settings.system.credentials.title': 'Protección de credenciales', 'settings.system.credentials.title': 'Protección de credenciales',
'settings.system.credentials.status': 'Estado', 'settings.system.credentials.status': 'Estado',
'settings.system.credentials.checking': 'Comprobando...', 'settings.system.credentials.checking': 'Comprobando...',
@@ -556,6 +587,8 @@ Resalta el panel dividido enfocado:
'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación', 'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación',
'settings.appearance.windowOpacity': 'Opacidad de la ventana', 'settings.appearance.windowOpacity': 'Opacidad de la ventana',
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.', 'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado',
'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.',
'settings.appearance.appIcon': 'Ícono de la aplicación', 'settings.appearance.appIcon': 'Ícono de la aplicación',
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.', 'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
'settings.appearance.appIcon.original': 'Original', 'settings.appearance.appIcon.original': 'Original',
@@ -1235,6 +1268,8 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido', 'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}', 'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins', 'settings.tab.plugins': 'Plugins',
"settings.plugins.author.official": "Mantenimiento oficial",
"settings.plugins.author.officialMeaning": "Mantenido por el proyecto Netcatty. No es una certificación de seguridad.",
"settings.plugins.manager.installed": "Plugins instalados", "settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg", "settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.", "settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
@@ -1262,9 +1297,21 @@ Resalta el panel dividido enfocado:
"plugins.interaction.share": "Compartir con el complemento", "plugins.interaction.share": "Compartir con el complemento",
"plugins.interaction.copy": "Copiar al portapapeles", "plugins.interaction.copy": "Copiar al portapapeles",
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.", "plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
"plugins.command.failed": "No se pudo completar el comando del complemento. Comprueba la selección requerida y los permisos.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Coincide con la ejecución actual", "settings.plugins.permissions.current": "Coincide con la ejecución actual",
"settings.plugins.permissions.inactive": "No activa para la ejecución actual", "settings.plugins.permissions.inactive": "No activa para la ejecución actual",
"settings.plugins.permissions.title": "Autorizaciones guardadas", "settings.plugins.permissions.note": "Las capacidades declaradas se permiten de forma predeterminada. Preguntar solicita una decisión con un alcance concreto; Denegar bloquea el acceso de inmediato. La selección de destinos y la edición de borradores siguen usando la interfaz de la aplicación.",
"settings.plugins.permissions.allow": "Permitir",
"settings.plugins.permissions.ask": "Preguntar",
"settings.plugins.permissions.deny": "Denegar",
"settings.plugins.permissions.title": "Permisos",
"settings.plugins.permissions.refresh": "Actualizar", "settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...", "settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.", "settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
@@ -1276,7 +1323,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.", "settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible", "settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados", "settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.", "settings.plugins.manager.permissionNote": "Las capacidades declaradas y compatibles se permiten de forma predeterminada. Puede cambiar cada capacidad a Preguntar o Denegar en los controles de permisos. Siempre se verifican los destinos y el alcance de los recursos.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.", "settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio", "settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional", "settings.plugins.manager.optional": "Opcional",
@@ -1319,7 +1366,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.", "settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.", "settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.", "settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.", "settings.plugins.manager.sensitive": "Acceso sensible: esta capacidad puede exponer datos privados. Su modo se puede cambiar en los ajustes de permisos.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.", "settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.", "settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar", "settings.plugins.market.retry": "Reintentar",
@@ -1328,11 +1375,14 @@ Resalta el panel dividido enfocado:
"settings.plugins.market.search": "Buscar nombre, autor o descripción", "settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.", "settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos", "settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.", "settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Se siguen aplicando la validación del anfitrión y el modo de permiso seleccionado.",
"settings.plugins.openView": "Abrir", "settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins", "settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:", "settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.", "settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Oficial",
"settings.plugins.sources.short.thirdparty": "Terceros",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace", "settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace", "settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources", "settings.plugins.sources.manage": "Manage sources",
@@ -1351,6 +1401,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.sources.local": "Local import", "settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources", "settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins", "settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Ajustes del plugin",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.", "settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…', 'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.', 'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
@@ -1380,5 +1431,20 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.', 'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.', 'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar', 'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Solo se comparten los nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados de los equipos seleccionados. Los grupos solo incluyen los equipos seleccionados ahora. Las solicitudes posteriores de edición o conexión siguen los permisos de este complemento.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
}; };
+4
View File
@@ -1,4 +1,8 @@
export const esMonitorMessages = { export const esMonitorMessages = {
"monitor.systemDisks": "Particiones del sistema ({count})",
"monitor.systemDisksAlert": "El uso de una partición del sistema supera el 80 %; se ha expandido automáticamente.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "E/S del dispositivo de bloques de este montaje. Los montajes del mismo dispositivo muestran las mismas tasas. No disponible hasta tener dos muestras válidas o sin un dispositivo identificable; no compatible con macOS.",
"monitor.diskIo": "E/S de disco", "monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura", "monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura", "monitor.diskWrite": "Escritura",
+20
View File
@@ -1,6 +1,24 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const esTerminalMessages: Messages = { export const esTerminalMessages: Messages = {
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
'terminal.directory.unknown': 'Directorio desconocido',
'terminal.directory.search': 'Buscar subdirectorios...',
'terminal.directory.directories': 'Directorios',
'terminal.directory.parent': '.. (Directorio padre)',
'terminal.directory.loading': 'Leyendo directorios...',
'terminal.directory.empty': 'Sin subdirectorios',
'terminal.directory.noMatches': 'Sin directorios coincidentes',
'terminal.directory.hidden': 'Mostrar directorios ocultos',
'terminal.directory.refresh': 'Actualizar',
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión', 'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
'terminal.toolbar.sessionLogActions': 'Acciones del registro', 'terminal.toolbar.sessionLogActions': 'Acciones del registro',
'terminal.toolbar.sessionLog.menu': 'Registro de sesión', 'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
@@ -460,6 +478,7 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.', 'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales', 'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.', 'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.folderWarning': 'Esta carpeta por sí sola no es una copia de seguridad completa y portátil. Eliminar o vaciar el perfil de la aplicación (userData) puede impedir la lectura de estos puntos de restauración.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad', 'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.', 'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
'cloudSync.localBackups.maxCount': 'Copias máximas', 'cloudSync.localBackups.maxCount': 'Copias máximas',
@@ -474,6 +493,7 @@ export const esTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.', 'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración', 'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.', 'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
'cloudSync.localBackups.decryptFailed': 'El perfil actual de la aplicación no puede descifrar esta copia. Puede faltar la clave de cifrado, haber cambiado el perfil o estar dañada la copia.',
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}', 'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?', 'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.', 'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
+3
View File
@@ -965,6 +965,9 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.', 'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH', 'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.', 'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso', 'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado', 'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts', 'hostDetails.jumpHosts': 'Proxy mediante hosts',
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getBaseFallbackLocale,
getLoadedLocaleMessages,
getLocaleMessagesVersion,
loadLocaleMessages,
subscribeLocaleMessages,
} from '../messages';
test('only English is loaded until another locale is requested', () => {
assert.ok(getLoadedLocaleMessages('en'));
assert.equal(getLoadedLocaleMessages('ru'), undefined);
});
test('zh-TW resolves through zh-CN, matching the old static lookup order', () => {
assert.equal(getBaseFallbackLocale('zh-TW'), 'zh-CN');
assert.equal(getBaseFallbackLocale('zh-CN'), 'zh-CN');
assert.equal(getBaseFallbackLocale('es'), 'es');
assert.equal(getBaseFallbackLocale('fr'), undefined);
});
test('loading a locale also loads its fallback and notifies subscribers once each', async () => {
const before = getLocaleMessagesVersion();
let notified = 0;
const unsubscribe = subscribeLocaleMessages(() => { notified += 1; });
await Promise.all([loadLocaleMessages('zh-TW'), loadLocaleMessages('zh-TW')]);
unsubscribe();
assert.ok(getLoadedLocaleMessages('zh-TW'));
assert.ok(getLoadedLocaleMessages('zh-CN'), 'base fallback is loaded with zh-TW');
assert.equal(notified, 2, 'one notification per newly loaded dictionary');
assert.equal(getLocaleMessagesVersion(), before + 2);
await loadLocaleMessages('zh-TW');
assert.equal(getLocaleMessagesVersion(), before + 2, 'already loaded locales are not reloaded');
});
+52
View File
@@ -71,6 +71,7 @@ export const ruAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.', 'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096', 'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
'ai.providers.advancedParams.default': 'По умолчанию у провайдера', 'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
// AI Codex // AI Codex
'ai.codex': 'Codex', 'ai.codex': 'Codex',
@@ -464,6 +465,8 @@ export const ruAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Всегда разрешать', 'ai.chat.alwaysAllow': 'Всегда разрешать',
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты', 'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора', 'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
'ai.chat.reject': 'Отклонить', 'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент', 'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель', 'ai.chat.targetLabel': 'Цель',
@@ -526,6 +529,28 @@ export const ruAiMessages: Messages = {
'ai.chat.recent': 'Недавние', 'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё', 'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия', 'ai.chat.untitled': 'Без названия',
'ai.chat.renameChat': 'Переименовать чат',
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
'ai.chat.renameDone': 'Чат переименован в «{title}».',
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
'ai.chat.importSessions': 'Импортировать чаты ИИ',
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Терминал',
'ai.chat.chatsScopeGlobal': 'Глобально',
'ai.chat.chatsScopeDangling': 'Связь потеряна',
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
'ai.chat.justNow': 'Только что', 'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад', 'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад', 'ai.chat.hoursAgo': '{n}ч назад',
@@ -575,6 +600,10 @@ export const ruAiMessages: Messages = {
'ai.chatShortcuts.title': 'Быстрые действия чата', 'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале', 'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.', 'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
'ai.chatHistory.title': 'История чата',
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
'ai.chatHistory.messageCap.unit': 'сообщений',
// AI Error // AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.', 'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
@@ -724,4 +753,27 @@ export const ruAiMessages: Messages = {
'zmodem.overwrite.skip': 'Пропустить', 'zmodem.overwrite.skip': 'Пропустить',
'zmodem.overwrite.cancel': 'Отмена', 'zmodem.overwrite.cancel': 'Отмена',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию', 'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
"ai.toolSettings.privacyOn": "Скрытие текста включено",
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
"ai.toolSettings.configurePrivacy": "Настроить приватность",
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
"ai.toolSettings.viewSkills": "Инструкции CLI",
"ai.toolSettings.viewPrompt": "Инструкции подключения",
"ai.toolSettings.behavior": "Поведение MCP",
"ai.toolSettings.configureBehavior": "Настроить поведение",
"ai.toolSettings.configureClients": "Настроить клиенты",
"ai.toolSettings.httpOn": "HTTP включён",
"ai.toolSettings.httpOff": "HTTP выключен",
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
"ai.toolSettings.configureHttp": "Настроить HTTP",
"ai.toolSettings.hideSecret": "Скрыть секрет",
"ai.toolSettings.showSecret": "Показать секрет",
"ai.toolSettings.manualSetup": "Ручная настройка",
"ai.toolSettings.format": "Формат",
"ai.toolSettings.connectionDetails": "Параметры подключения",
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
"ai.toolSettings.retry": "Повторить",
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
}; };
+40 -4
View File
@@ -134,7 +134,12 @@ export const ruCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Очистить временные файлы', 'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...', 'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.', 'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.', 'settings.system.tempDirChoose': 'Выбрать папку',
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
'settings.system.tempDirApply': 'Применить',
'settings.system.tempDirReset': 'Сбросить по умолчанию',
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
'settings.system.credentials.title': 'Защита учётных данных', 'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус', 'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...', 'settings.system.credentials.checking': 'Проверка...',
@@ -525,6 +530,8 @@ export const ruCoreMessages: Messages = {
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения', 'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна', 'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.', 'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
'settings.appearance.windowBackgroundBlur': 'Матовое стекло',
'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.',
'settings.appearance.appIcon': 'Значок приложения', 'settings.appearance.appIcon': 'Значок приложения',
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.', 'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
'settings.appearance.appIcon.original': 'Оригинал', 'settings.appearance.appIcon.original': 'Оригинал',
@@ -1130,6 +1137,8 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}', 'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины', 'settings.tab.plugins': 'Плагины',
"settings.plugins.author.official": "Официальная поддержка",
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
"settings.plugins.manager.installed": "Установленные плагины", "settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg", "settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.", "settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
@@ -1157,6 +1166,14 @@ export const ruCoreMessages: Messages = {
"plugins.interaction.share": "Передать плагину", "plugins.interaction.share": "Передать плагину",
"plugins.interaction.copy": "Копировать в буфер обмена", "plugins.interaction.copy": "Копировать в буфер обмена",
"plugins.interaction.failed": "Запрос истёк или не выполнен.", "plugins.interaction.failed": "Запрос истёк или не выполнен.",
"plugins.command.failed": "Не удалось выполнить команду плагина. Проверьте необходимые выбранные объекты и разрешения.",
"plugins.interaction.terminalDisclosure": "Only the current selection from this terminal is shared, up to 4096 UTF-8 bytes. Terminal text can contain passwords, tokens, and other secrets. Inspect the exact preview before sharing. No redaction guarantee is made.",
"plugins.interaction.terminalTarget": "Selected terminal",
"plugins.interaction.terminalPreview": "Preview terminal selection",
"plugins.interaction.draftNote": "Editable draft for review only. This dialog sends no terminal input or Enter. Copy text yourself and use the normal terminal UI if you choose to send it. Your edits stay in this dialog.",
"plugins.interaction.draft": "Terminal input draft",
"plugins.interaction.exactText": "Exact text with escaped controls",
"plugins.interaction.closeDraft": "Close draft",
"settings.plugins.permissions.current": "Соответствует текущему запуску", "settings.plugins.permissions.current": "Соответствует текущему запуску",
"settings.plugins.permissions.inactive": "Не действует для текущего запуска", "settings.plugins.permissions.inactive": "Не действует для текущего запуска",
"settings.plugins.permissions.title": "Сохранённые разрешения", "settings.plugins.permissions.title": "Сохранённые разрешения",
@@ -1171,7 +1188,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.", "settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно", "settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения", "settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.", "settings.plugins.manager.permissionNote": "Заявленные поддерживаемые возможности по умолчанию разрешены. Для каждой можно выбрать запрос или запрет в настройках разрешений. Проверки приложения и область ресурсов действуют всегда.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.", "settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно", "settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно", "settings.plugins.manager.optional": "Необязательно",
@@ -1214,7 +1231,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.", "settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.", "settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.", "settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.", "settings.plugins.manager.sensitive": "Чувствительный доступ: эта возможность может раскрыть личные данные. Её режим можно изменить в настройках разрешений.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.", "settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.", "settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить", "settings.plugins.market.retry": "Повторить",
@@ -1223,11 +1240,14 @@ export const ruCoreMessages: Messages = {
"settings.plugins.market.search": "Поиск по имени, автору или описанию", "settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.", "settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения", "settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.", "settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверки приложения и выбранный режим разрешений продолжают действовать.",
"settings.plugins.openView": "Открыть", "settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами", "settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:", "settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.", "settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Официальный",
"settings.plugins.sources.short.thirdparty": "Сторонний",
"settings.plugins.sources.short.local": "Локальный",
"settings.plugins.sources.official": "Official marketplace", "settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace", "settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources", "settings.plugins.sources.manage": "Manage sources",
@@ -1246,6 +1266,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.sources.local": "Local import", "settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources", "settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины", "settings.plugins.title": "Плагины",
"settings.plugins.configuration": "Настройки плагина",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.", "settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…', 'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.', 'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
@@ -1275,4 +1296,19 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.', 'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.', 'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить', 'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Передаются только метаданные выбранных хостов: сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Дальнейшие запросы изменения или подключения зависят от настроек разрешений плагина.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
}; };
+4
View File
@@ -1,4 +1,8 @@
export const ruMonitorMessages = { export const ruMonitorMessages = {
"monitor.systemDisks": "Системные разделы ({count})",
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
"monitor.diskIoTotal": "Всего",
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
"monitor.diskIo": "Дисковый ввод-вывод", "monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение", "monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись", "monitor.diskWrite": "Запись",
+20
View File
@@ -1,6 +1,24 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const ruTerminalMessages: Messages = { export const ruTerminalMessages: Messages = {
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
'terminal.directory.unknown': 'Каталог неизвестен',
'terminal.directory.search': 'Поиск подкаталогов...',
'terminal.directory.directories': 'Каталоги',
'terminal.directory.parent': '.. (Родительский каталог)',
'terminal.directory.loading': 'Чтение каталогов...',
'terminal.directory.empty': 'Нет подкаталогов',
'terminal.directory.noMatches': 'Нет подходящих каталогов',
'terminal.directory.hidden': 'Показывать скрытые каталоги',
'terminal.directory.refresh': 'Обновить',
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
'settings.terminal.behavior.contextChips': 'Context Chips',
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии', 'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
'terminal.toolbar.sessionLogActions': 'Действия с журналом', 'terminal.toolbar.sessionLogActions': 'Действия с журналом',
'terminal.toolbar.sessionLog.menu': 'Журнал сессии', 'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
@@ -478,6 +496,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.', 'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
'cloudSync.localBackups.title': 'История локальных резервных копий', 'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.', 'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.folderWarning': 'Эта папка сама по себе не является полной переносимой резервной копией. Удаление или очистка профиля приложения (userData) может сделать эти точки восстановления нечитаемыми.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий', 'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.', 'cloudSync.localBackups.retentionDesc': 'Выберите, сколько локальных резервных копий должен хранить Netcatty.',
'cloudSync.localBackups.maxCount': 'Макс. число копий', 'cloudSync.localBackups.maxCount': 'Макс. число копий',
@@ -492,6 +511,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.', 'cloudSync.localBackups.restoreSuccess': 'Локальная резервная копия восстановлена.',
'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления', 'cloudSync.localBackups.restoreFailedTitle': 'Ошибка восстановления',
'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.', 'cloudSync.localBackups.restoreMissing': 'Резервная копия не найдена.',
'cloudSync.localBackups.decryptFailed': 'Текущий профиль приложения не может расшифровать эту копию. Возможно, отсутствует ключ шифрования, профиль изменился или копия повреждена.',
'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}', 'cloudSync.localBackups.protectiveBackupFailed': 'Не удалось создать защитную резервную копию, поэтому восстановление было прервано для защиты ваших текущих данных. Устраните основную проблему (например, доступ к keychain) и попробуйте снова. Подробности: {message}',
'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?', 'cloudSync.localBackups.restoreConfirmTitle': 'Восстановить эту резервную копию?',
'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.', 'cloudSync.localBackups.restoreConfirmDesc': 'Ваши текущие хосты, ключи, сниппеты и настройки будут заменены содержимым этой резервной копии. Перед этим автоматически создаётся защитный снимок текущих данных.',
+3
View File
@@ -1014,6 +1014,9 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.', 'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа', 'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.', 'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace', 'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию', 'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты', 'hostDetails.jumpHosts': 'Прокси через хосты',
@@ -29,6 +29,16 @@ const ALL_SETTINGS_LOCALES = [
...LOCALIZED_SETTINGS_LOCALES, ...LOCALIZED_SETTINGS_LOCALES,
]; ];
test("Host selection disclosure describes later permission-controlled actions without promising another confirmation", () => {
const permissionWords: Record<string, RegExp> = { en: /permission settings/i, es: /permisos/i, ru: /разрешени/u, 'zh-CN': /权限设置/u, 'zh-TW': /權限設定/u };
const oldPromise = /requires a separate confirmation|requiere otra confirmación|требует отдельного подтверждения|需要单独确认|需要另外確認/iu;
for (const locale of ALL_SETTINGS_LOCALES) {
const note = locale.messages['plugins.hostActions.selectionNote'];
assert.match(note, permissionWords[locale.name], locale.name);
assert.doesNotMatch(note, oldPromise, locale.name);
}
});
test("localized settings include names for every default shortcut", () => { test("localized settings include names for every default shortcut", () => {
for (const locale of ALL_SETTINGS_LOCALES) { for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS const missing = DEFAULT_KEY_BINDINGS
+52
View File
@@ -99,6 +99,7 @@ export const zhCNAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。', 'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096', 'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认', 'ai.providers.advancedParams.default': '提供商默认',
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
// AI Codex // AI Codex
'ai.codex': 'Codex', 'ai.codex': 'Codex',
@@ -525,6 +526,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.alwaysAllow': '始终', 'ai.chat.alwaysAllow': '始终',
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具', 'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
'ai.chat.slashCompactDesc': '压缩较早的会话上下文', 'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
'ai.chat.reject': '拒绝', 'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具', 'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标', 'ai.chat.targetLabel': '目标',
@@ -587,6 +590,28 @@ export const zhCNAiMessages: Messages = {
'ai.chat.recent': '最近', 'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部', 'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题', 'ai.chat.untitled': '无标题',
'ai.chat.renameChat': '重命名聊天',
'ai.chat.renameUsage': '用法:/rename <新标题>',
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
'ai.chat.exportSessions': '导出 AI 会话',
'ai.chat.importSessions': '导入 AI 会话',
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
'ai.chat.chatsHeader': '此面板可见的聊天:',
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
'ai.chat.chatsScopeWorkspace': '工作区',
'ai.chat.chatsScopeTerminal': '终端',
'ai.chat.chatsScopeGlobal': '全局',
'ai.chat.chatsScopeDangling': '已断开关联',
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
'ai.chat.rebindUsage': '用法:/rebind <序号>',
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
'ai.chat.justNow': '刚刚', 'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前', 'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前', 'ai.chat.hoursAgo': '{n}小时前',
@@ -636,6 +661,10 @@ export const zhCNAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口', 'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”', 'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。', 'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
'ai.chatHistory.title': '聊天历史',
'ai.chatHistory.messageCap': '每个会话保留的消息数',
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
'ai.chatHistory.messageCap.unit': '条消息',
// AI Error // AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。', 'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
@@ -795,4 +824,27 @@ export const zhCNAiMessages: Messages = {
'zmodem.overwrite.skip': '跳过', 'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消', 'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认', 'settings.shortcuts.resetToDefault': '重置为默认',
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
"ai.toolSettings.privacyOn": "已启用文本脱敏",
"ai.toolSettings.privacyOff": "未启用文本脱敏",
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
"ai.toolSettings.configurePrivacy": "配置隐私",
"ai.toolSettings.unsaved": "有未保存的更改",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示词",
"ai.toolSettings.behavior": "MCP 行为",
"ai.toolSettings.configureBehavior": "配置行为",
"ai.toolSettings.configureClients": "配置客户端",
"ai.toolSettings.httpOn": "HTTP 已启用",
"ai.toolSettings.httpOff": "HTTP 未启用",
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
"ai.toolSettings.configureHttp": "配置 HTTP",
"ai.toolSettings.hideSecret": "隐藏密钥",
"ai.toolSettings.showSecret": "显示密钥",
"ai.toolSettings.manualSetup": "手动配置",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "连接详情",
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
"ai.toolSettings.retry": "重试",
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
}; };
+73 -7
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = { export const zhCNCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在载入已安装插件…",
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
"settings.plugins.landing.options": "插件选项",
"settings.plugins.landing.manager": "插件管理器",
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
"settings.plugins.landing.browse": "浏览",
"settings.plugins.landing.refresh": "刷新已安装插件",
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
"settings.plugins.landing.search": "搜索已安装插件",
"settings.plugins.landing.settingsFor": "{name} 的设置",
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
"settings.plugins.landing.sourceUnknown": "来源未知",
"settings.plugins.landing.status.running": "运行中",
"settings.plugins.landing.status.starting": "启动中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "错误",
"settings.plugins.landing.status.quarantined": "已隔离",
"settings.plugins.landing.status.unknown": "状态未知",
"settings.plugins.landing.status.idle": "已启用,空闲",
// Common // Common
'common.save': '保存', 'common.save': '保存',
'common.cancel': '取消', 'common.cancel': '取消',
@@ -139,7 +165,12 @@ export const zhCNCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理临时文件', 'settings.system.clearTempFiles': '清理临时文件',
'settings.system.clearing': '清理中...', 'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。', 'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。', 'settings.system.tempDirChoose': '选择文件夹',
'settings.system.tempDirManual': '输入绝对文件夹路径',
'settings.system.tempDirApply': '应用',
'settings.system.tempDirReset': '恢复默认',
'settings.system.tempDirError': '无法使用此文件夹。',
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
'settings.system.credentials.title': '凭据保护', 'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态', 'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...', 'settings.system.credentials.checking': '检查中...',
@@ -560,6 +591,8 @@ Vaults 标签保持扁平,不使用这块背景。
'settings.appearance.uiFont.desc': '选择软件界面使用的字体', 'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度', 'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。', 'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。',
'settings.appearance.appIcon': '应用图标', 'settings.appearance.appIcon': '应用图标',
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。', 'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
'settings.appearance.appIcon.original': '原始', 'settings.appearance.appIcon.original': '原始',
@@ -1151,6 +1184,8 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.default': '默认', 'qs.default': '默认',
'settings.tab.plugins': '插件', 'settings.tab.plugins': '插件',
"settings.plugins.manager.installed": "已安装插件", "settings.plugins.manager.installed": "已安装插件",
"settings.plugins.author.official": "官方维护",
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
"settings.plugins.manager.import": "导入 .ncpkg", "settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。", "settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。", "settings.plugins.manager.empty": "导入本地插件包以开始使用。",
@@ -1177,21 +1212,33 @@ Vaults 标签保持扁平,不使用这块背景。
"plugins.interaction.share": "分享给插件", "plugins.interaction.share": "分享给插件",
"plugins.interaction.copy": "复制到剪贴板", "plugins.interaction.copy": "复制到剪贴板",
"plugins.interaction.failed": "此请求已过期或无法完成。", "plugins.interaction.failed": "此请求已过期或无法完成。",
"plugins.command.failed": "无法完成插件命令,请检查所需选区或所选对象及权限。",
"plugins.interaction.terminalDisclosure": "仅分享此终端的当前选区,最多 4096 UTF-8 字节。终端文本可能包含密码、令牌或其他秘密。分享前请检查完整预览;不保证自动脱敏。",
"plugins.interaction.terminalTarget": "所选终端",
"plugins.interaction.terminalPreview": "预览终端选区",
"plugins.interaction.draftNote": "此可编辑草稿仅供审阅,不向终端发送输入或回车。如决定发送,请自行复制并使用常规终端界面。编辑内容仅保留在此对话框。",
"plugins.interaction.draft": "终端输入草稿",
"plugins.interaction.exactText": "转义控制字符后的完整文本",
"plugins.interaction.closeDraft": "关闭草稿",
"settings.plugins.permissions.current": "匹配当前运行实例", "settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效", "settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.title": "已保存的授权", "settings.plugins.permissions.note": "已声明且受支持的权限默认允许。询问时显示一次范围明确的确认;拒绝会立即阻止访问。选择目标和编辑草稿仍使用宿主界面。",
"settings.plugins.permissions.allow": "允许",
"settings.plugins.permissions.ask": "询问",
"settings.plugins.permissions.deny": "拒绝",
"settings.plugins.permissions.title": "权限",
"settings.plugins.permissions.refresh": "刷新", "settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...", "settings.plugins.permissions.loading": "正在加载权限...",
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。", "settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销", "settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty", "settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭", "settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)", "settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权", "settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。", "settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。允许 / 询问 / 拒绝设置会保留。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用", "settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限", "settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。", "settings.plugins.manager.permissionNote": "已声明且受支持的权限默认允许。可以在权限设置中逐项改为询问或拒绝;宿主验证和资源范围限制始终生效。",
"settings.plugins.manager.noPermissions": "未声明特权权限。", "settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需", "settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选", "settings.plugins.manager.optional": "可选",
@@ -1234,7 +1281,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。", "settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。", "settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。", "settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。", "settings.plugins.manager.sensitive": "敏感访问:此能力可能涉及私密数据,可在权限设置中调整其模式。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。", "settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。", "settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试", "settings.plugins.market.retry": "重试",
@@ -1243,11 +1290,14 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.market.search": "搜索名称、作者或描述", "settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。", "settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限", "settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。", "settings.plugins.manager.unbounded": "未声明更细的资源范围,仍受应用校验和所选权限模式约束。",
"settings.plugins.openView": "打开", "settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理", "settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:", "settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。", "settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本地",
"settings.plugins.sources.official": "官方市场", "settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场", "settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源", "settings.plugins.sources.manage": "管理来源",
@@ -1266,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.sources.local": "本地导入", "settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源", "settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件", "settings.plugins.title": "插件",
"settings.plugins.configuration": "插件设置",
"settings.plugins.description": "管理已安装插件,浏览精选目录。", "settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…', 'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。', 'settings.plugins.empty': '当前没有已启用插件提供设置。',
@@ -1295,5 +1346,20 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。', 'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。', 'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续', 'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '仅向此插件共享所选主机的元数据:名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。后续编辑或连接请求遵循此插件的权限设置。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
'plugins.hostActions.fieldDiff': '即将保存的修改',
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
}; };
@@ -1,4 +1,8 @@
export const zhCNMonitorMessages = { export const zhCNMonitorMessages = {
"monitor.systemDisks": "系统分区({count})",
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
"monitor.diskIoTotal": "汇总",
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
"monitor.diskIo": "磁盘 I/O", "monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取", "monitor.diskRead": "读取",
"monitor.diskWrite": "写入", "monitor.diskWrite": "写入",
@@ -1,6 +1,24 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = { export const zhCNTerminalMessages: Messages = {
'terminal.directory.open': '当前目录:{path}。更改目录',
'terminal.directory.unknown': '目录未知',
'terminal.directory.search': '搜索当前目录下的文件夹…',
'terminal.directory.directories': '目录',
'terminal.directory.parent': '..(父目录)',
'terminal.directory.loading': '正在读取目录…',
'terminal.directory.empty': '没有子目录',
'terminal.directory.noMatches': '没有匹配的子目录',
'terminal.directory.hidden': '显示隐藏目录',
'terminal.directory.refresh': '刷新',
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
'terminal.directory.busy': '请等待终端回到空命令提示符。',
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
'terminal.toolbar.openSessionLog': '打开会话日志', 'terminal.toolbar.openSessionLog': '打开会话日志',
'terminal.toolbar.sessionLogActions': '会话日志操作', 'terminal.toolbar.sessionLogActions': '会话日志操作',
'terminal.toolbar.sessionLog.menu': '会话日志', 'terminal.toolbar.sessionLog.menu': '会话日志',
+5
View File
@@ -384,6 +384,9 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。', 'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)', 'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。', 'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为', 'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认', 'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理', 'hostDetails.jumpHosts': '通过主机代理',
@@ -885,6 +888,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。', 'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
'cloudSync.localBackups.title': '本地备份历史', 'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。', 'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.folderWarning': '仅复制此文件夹并不构成完整、可迁移的备份。删除或清空应用配置目录(userData)可能导致这些恢复点无法读取。',
'cloudSync.localBackups.retentionTitle': '备份保留数量', 'cloudSync.localBackups.retentionTitle': '备份保留数量',
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。', 'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
'cloudSync.localBackups.maxCount': '最多保留', 'cloudSync.localBackups.maxCount': '最多保留',
@@ -899,6 +903,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。', 'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败', 'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
'cloudSync.localBackups.restoreMissing': '找不到这份备份。', 'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
'cloudSync.localBackups.decryptFailed': '当前应用配置无法解密此备份。可能是加密密钥丢失、应用配置已更改,或备份已损坏。',
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}', 'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?', 'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。', 'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
+52
View File
@@ -90,6 +90,7 @@ export const zhTWAiMessages: Messages = {
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。', 'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096', 'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '供應商預設', 'ai.providers.advancedParams.default': '供應商預設',
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
// AI Codex // AI Codex
'ai.codex': 'Codex', 'ai.codex': 'Codex',
@@ -515,6 +516,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.alwaysAllow': '始終', 'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具', 'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文', 'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
'ai.chat.reject': '拒絕', 'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具', 'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標', 'ai.chat.targetLabel': '目標',
@@ -577,6 +580,28 @@ export const zhTWAiMessages: Messages = {
'ai.chat.recent': '最近', 'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部', 'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題', 'ai.chat.untitled': '無標題',
'ai.chat.renameChat': '重新命名聊天',
'ai.chat.renameUsage': '用法:/rename <新標題>',
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
'ai.chat.exportSessions': '匯出 AI 會話',
'ai.chat.importSessions': '匯入 AI 會話',
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
'ai.chat.chatsHeader': '此面板可見的聊天:',
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
'ai.chat.chatsScopeWorkspace': '工作區',
'ai.chat.chatsScopeTerminal': '終端',
'ai.chat.chatsScopeGlobal': '全域',
'ai.chat.chatsScopeDangling': '已斷開關聯',
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
'ai.chat.rebindUsage': '用法:/rebind <序號>',
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
'ai.chat.justNow': '剛剛', 'ai.chat.justNow': '剛剛',
'ai.chat.minutesAgo': '{n}分鐘前', 'ai.chat.minutesAgo': '{n}分鐘前',
'ai.chat.hoursAgo': '{n}小時前', 'ai.chat.hoursAgo': '{n}小時前',
@@ -626,6 +651,10 @@ export const zhTWAiMessages: Messages = {
'ai.chatShortcuts.title': '聊天快捷入口', 'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」', 'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。', 'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
'ai.chatHistory.title': '聊天歷史',
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
'ai.chatHistory.messageCap.unit': '則訊息',
// AI Error // AI Error
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。', 'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
@@ -785,4 +814,27 @@ export const zhTWAiMessages: Messages = {
'zmodem.overwrite.skip': '跳過', 'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消', 'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重設為預設值', 'settings.shortcuts.resetToDefault': '重設為預設值',
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
"ai.toolSettings.configurePrivacy": "設定隱私",
"ai.toolSettings.unsaved": "有未儲存的變更",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示詞",
"ai.toolSettings.behavior": "MCP 行為",
"ai.toolSettings.configureBehavior": "設定行為",
"ai.toolSettings.configureClients": "設定用戶端",
"ai.toolSettings.httpOn": "HTTP 已啟用",
"ai.toolSettings.httpOff": "HTTP 未啟用",
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
"ai.toolSettings.configureHttp": "設定 HTTP",
"ai.toolSettings.hideSecret": "隱藏金鑰",
"ai.toolSettings.showSecret": "顯示金鑰",
"ai.toolSettings.manualSetup": "手動設定",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "連線詳情",
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
"ai.toolSettings.retry": "重試",
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
}; };
+73 -7
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = { export const zhTWCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
"settings.plugins.landing.options": "外掛選項",
"settings.plugins.landing.manager": "外掛管理器",
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
"settings.plugins.landing.browse": "瀏覽",
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
"settings.plugins.landing.search": "搜尋已安裝外掛",
"settings.plugins.landing.settingsFor": "{name} 的設定",
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
"settings.plugins.landing.sourceUnknown": "來源不明",
"settings.plugins.landing.status.running": "執行中",
"settings.plugins.landing.status.starting": "啟動中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "錯誤",
"settings.plugins.landing.status.quarantined": "已隔離",
"settings.plugins.landing.status.unknown": "狀態不明",
"settings.plugins.landing.status.idle": "已啟用,閒置",
// Common // Common
'common.save': '儲存', 'common.save': '儲存',
'common.cancel': '取消', 'common.cancel': '取消',
@@ -141,7 +167,12 @@ export const zhTWCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理臨時檔案', 'settings.system.clearTempFiles': '清理臨時檔案',
'settings.system.clearing': '清理中...', 'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。', 'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。', 'settings.system.tempDirChoose': '選擇資料夾',
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
'settings.system.tempDirApply': '套用',
'settings.system.tempDirReset': '恢復預設',
'settings.system.tempDirError': '無法使用此資料夾。',
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
'settings.system.credentials.title': '憑證保護', 'settings.system.credentials.title': '憑證保護',
'settings.system.credentials.status': '狀態', 'settings.system.credentials.status': '狀態',
'settings.system.credentials.checking': '檢查中...', 'settings.system.credentials.checking': '檢查中...',
@@ -562,6 +593,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型', 'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型',
'settings.appearance.windowOpacity': '視窗透明度', 'settings.appearance.windowOpacity': '視窗透明度',
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。', 'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
'settings.appearance.windowBackgroundBlur': '毛玻璃背景',
'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。',
'settings.appearance.appIcon': '應用程式圖示', 'settings.appearance.appIcon': '應用程式圖示',
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。', 'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
'settings.appearance.appIcon.original': '原始', 'settings.appearance.appIcon.original': '原始',
@@ -1153,6 +1186,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設', 'qs.default': '預設',
'settings.tab.plugins': '外掛程式', 'settings.tab.plugins': '外掛程式',
"settings.plugins.author.official": "官方維護",
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
"settings.plugins.manager.installed": "已安裝插件", "settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg", "settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。", "settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
@@ -1180,21 +1215,33 @@ Vaults 分頁維持扁平,不使用這塊背景。
"plugins.interaction.share": "分享給外掛", "plugins.interaction.share": "分享給外掛",
"plugins.interaction.copy": "複製到剪貼簿", "plugins.interaction.copy": "複製到剪貼簿",
"plugins.interaction.failed": "此請求已過期或無法完成。", "plugins.interaction.failed": "此請求已過期或無法完成。",
"plugins.command.failed": "無法完成外掛指令,請檢查所需選取範圍或所選項目及權限。",
"plugins.interaction.terminalDisclosure": "僅分享此終端的目前選取範圍,最多 4096 UTF-8 位元組。終端文字可能包含密碼、權杖或其他秘密。分享前請檢查完整預覽;不保證自動遮蔽。",
"plugins.interaction.terminalTarget": "所選終端",
"plugins.interaction.terminalPreview": "預覽終端選取範圍",
"plugins.interaction.draftNote": "此可編輯草稿僅供審閱,不向終端傳送輸入或 Enter。如決定傳送,請自行複製並使用一般終端介面。編輯內容僅保留在此對話框。",
"plugins.interaction.draft": "終端輸入草稿",
"plugins.interaction.exactText": "逸出控制字元後的完整文字",
"plugins.interaction.closeDraft": "關閉草稿",
"settings.plugins.permissions.current": "符合目前執行個體", "settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效", "settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.title": "已儲存的授權", "settings.plugins.permissions.note": "已宣告且受支援的權限預設允許。詢問時顯示一次範圍明確的確認;拒絕會立即阻止存取。選擇目標和編輯草稿仍使用宿主介面。",
"settings.plugins.permissions.allow": "允許",
"settings.plugins.permissions.ask": "詢問",
"settings.plugins.permissions.deny": "拒絕",
"settings.plugins.permissions.title": "權限",
"settings.plugins.permissions.refresh": "重新整理", "settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...", "settings.plugins.permissions.loading": "正在載入權限...",
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。", "settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷", "settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty", "settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉", "settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)", "settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權", "settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。", "settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。允許 / 詢問 / 拒絕設定會保留。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用", "settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限", "settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。", "settings.plugins.manager.permissionNote": "已宣告且受支援的權限預設允許。可以在權限設定中逐項改為詢問或拒絕;宿主驗證和資源範圍限制始終生效。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。", "settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需", "settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選", "settings.plugins.manager.optional": "可選",
@@ -1214,11 +1261,14 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。", "settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。", "settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。", "settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。", "settings.plugins.manager.unbounded": "未宣告更細的資源範圍,仍受應用程式驗證和所選權限模式約束。",
"settings.plugins.openView": "開啟", "settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理", "settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:", "settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。", "settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本機",
"settings.plugins.sources.official": "官方市场", "settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场", "settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源", "settings.plugins.sources.manage": "管理来源",
@@ -1237,6 +1287,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.sources.local": "本地导入", "settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源", "settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件", "settings.plugins.title": "插件",
"settings.plugins.configuration": "插件設定",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。", "settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…', 'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件", "settings.plugins.market.results": "{count} 個插件",
@@ -1262,7 +1313,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。", "settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。", "settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。", "settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。", "settings.plugins.manager.sensitive": "敏感存取:此能力可能涉及私密資料,可在權限設定中調整其模式。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。", "settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。", "settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試", "settings.plugins.market.retry": "重試",
@@ -1298,4 +1349,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。', 'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。', 'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續', 'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '僅向此外掛分享所選主機的中繼資料:名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。後續編輯或連線請求遵循此外掛的權限設定。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
'plugins.hostActions.fieldDiff': '即將儲存的修改',
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
}; };
@@ -1,4 +1,8 @@
export const zhTWMonitorMessages = { export const zhTWMonitorMessages = {
"monitor.systemDisks": "系統分割區({count})",
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
"monitor.diskIoTotal": "彙總",
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
"monitor.diskIo": "磁碟 I/O", "monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取", "monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入", "monitor.diskWrite": "寫入",
@@ -1,6 +1,24 @@
import type { Messages } from '../types'; import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = { export const zhTWTerminalMessages: Messages = {
'terminal.directory.open': '目前目錄:{path}。變更目錄',
'terminal.directory.unknown': '目錄未知',
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
'terminal.directory.directories': '目錄',
'terminal.directory.parent': '..(父目錄)',
'terminal.directory.loading': '正在讀取目錄…',
'terminal.directory.empty': '沒有子目錄',
'terminal.directory.noMatches': '沒有符合的子目錄',
'terminal.directory.hidden': '顯示隱藏目錄',
'terminal.directory.refresh': '重新整理',
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
'terminal.toolbar.openSessionLog': '開啟工作階段日誌', 'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
'terminal.toolbar.sessionLogActions': '工作階段日誌操作', 'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
'terminal.toolbar.sessionLog.menu': '工作階段日誌', 'terminal.toolbar.sessionLog.menu': '工作階段日誌',
+5
View File
@@ -384,6 +384,9 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。', 'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)', 'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。', 'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為', 'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設', 'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理', 'hostDetails.jumpHosts': '透過主機代理',
@@ -885,6 +888,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。', 'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
'cloudSync.localBackups.title': '本機備份歷史', 'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。', 'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.folderWarning': '僅複製此資料夾並不構成完整、可攜的備份。刪除或清空應用程式設定目錄(userData)可能導致這些還原點無法讀取。',
'cloudSync.localBackups.retentionTitle': '備份保留數量', 'cloudSync.localBackups.retentionTitle': '備份保留數量',
'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。', 'cloudSync.localBackups.retentionDesc': '設定 Netcatty 最多保留多少份本機備份。',
'cloudSync.localBackups.maxCount': '最多保留', 'cloudSync.localBackups.maxCount': '最多保留',
@@ -899,6 +903,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。', 'cloudSync.localBackups.restoreSuccess': '已恢復本機備份。',
'cloudSync.localBackups.restoreFailedTitle': '恢復失敗', 'cloudSync.localBackups.restoreFailedTitle': '恢復失敗',
'cloudSync.localBackups.restoreMissing': '找不到這份備份。', 'cloudSync.localBackups.restoreMissing': '找不到這份備份。',
'cloudSync.localBackups.decryptFailed': '目前的應用程式設定無法解密此備份。可能是加密金鑰遺失、應用程式設定已變更,或備份已損毀。',
'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}', 'cloudSync.localBackups.protectiveBackupFailed': '無法建立保護性備份,已中止恢復以避免覆蓋目前資料。請先解決底層問題(例如鑰匙串存取)後重試。詳情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?', 'cloudSync.localBackups.restoreConfirmTitle': '確認恢復此備份?',
'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。', 'cloudSync.localBackups.restoreConfirmDesc': '目前的主機、金鑰、程式碼片段與設定將被替換為此備份中的內容。系統會先自動建立一個保護性快照,便於復原。',
+72 -11
View File
@@ -1,17 +1,78 @@
import en, { type Messages } from './locales/en'; import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
import es from './locales/es';
// Keep keys stable; add new locales by adding another import and map entry. // Keep keys stable; add new locales by adding a loader below and an entry in
// SUPPORTED_UI_LOCALES (infrastructure/config/i18n.ts).
export { type Messages }; export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = { /**
en, * Lookup order for the "same base language" fallback in I18nProvider (for
ru, * example zh-TW falls back to zh-CN, the first `zh*` entry). Matches the key
es, * order of the old static MESSAGES_BY_LOCALE map.
'zh-CN': zhCN, */
'zh-TW': zhTW, export const LOCALE_MESSAGE_KEYS = ['en', 'ru', 'es', 'zh-CN', 'zh-TW'] as const;
/**
* Only English is bundled with the startup chunk; other locales are loaded on
* demand. All five dictionaries used to be parsed by every window at startup
* (~1.3 MB of the entry chunk) even though one locale is in use.
*/
const LOCALE_LOADERS: Record<string, () => Promise<{ default: Messages }>> = {
ru: () => import('./locales/ru'),
es: () => import('./locales/es'),
'zh-CN': () => import('./locales/zh-CN'),
'zh-TW': () => import('./locales/zh-TW'),
}; };
const loadedMessages: Record<string, Messages> = { en };
const pendingLoads = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
let messagesVersion = 0;
export function getLoadedLocaleMessages(locale: string): Messages | undefined {
return loadedMessages[locale];
}
export function getLocaleMessagesVersion(): number {
return messagesVersion;
}
export function subscribeLocaleMessages(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** The same-base fallback locale that I18nProvider consults for `locale`. */
export function getBaseFallbackLocale(locale: string): string | undefined {
const base = locale.split('-')[0];
return LOCALE_MESSAGE_KEYS.find((key) => key === base || key.startsWith(`${base}-`));
}
function loadOne(locale: string): Promise<void> {
if (loadedMessages[locale]) return Promise.resolve();
const loader = LOCALE_LOADERS[locale];
if (!loader) return Promise.resolve();
let pending = pendingLoads.get(locale);
if (!pending) {
pending = loader()
.then((module) => {
loadedMessages[locale] = module.default;
messagesVersion += 1;
for (const listener of listeners) listener();
})
.finally(() => {
pendingLoads.delete(locale);
});
pendingLoads.set(locale, pending);
}
return pending;
}
/** Load the dictionaries `locale` resolves through (itself and its base fallback). */
export function loadLocaleMessages(locale: string): Promise<void> {
const needed = new Set([locale]);
const fallback = getBaseFallbackLocale(locale);
if (fallback) needed.add(fallback);
return Promise.all([...needed].map(loadOne)).then(() => undefined);
}
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAgentSessionReconnect } from './agentSessionReconnect';
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
let calls = 0;
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
assert.equal(calls, 1);
});
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
});
@@ -0,0 +1,21 @@
type ReconnectRegistry = {
isActive: (sessionId: string) => boolean;
requestIfAvailable: (sessionId: string) => boolean;
};
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
export function requestAgentSessionReconnect(
sessionId: string,
status: string | undefined,
registry: ReconnectRegistry,
): Record<string, unknown> {
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
if (status === 'connecting' || registry.isActive(sessionId)) {
return { ok: true, started: false, sessionId, status: 'reconnecting' };
}
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
}
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
}
+1 -47
View File
@@ -2,14 +2,9 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { import type {
AIPanelView,
AISession, AISession,
} from "../../infrastructure/ai/types.ts"; } from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts"; import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession { function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return { return {
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
}; };
} }
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => { test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [ const sessions = [
createSession("terminal-restorable", { createSession("terminal-restorable", {
-36
View File
@@ -6,7 +6,6 @@ import type {
type DraftsByScope = Partial<Record<string, AIDraft>>; type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>; type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget( function isInactiveScopedTarget(
scopeKey: string, scopeKey: string,
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
}; };
} }
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
export function pruneInactiveScopedSessions( export function pruneInactiveScopedSessions(
sessions: AISession[], sessions: AISession[],
activeTargetIds: Set<string>, activeTargetIds: Set<string>,
@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
import {
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_MIGRATION_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbClearSessions,
idbDeleteSessionIds,
idbGetAllSessions,
idbGetMeta,
idbPutMeta,
idbPutSession,
idbPutSessions,
isIndexedDbAvailable,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
readActiveSessionMapFromIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'agent',
scope: { type: 'terminal', targetId: 't' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
test('module wrapper roundtrips sessions through the object stores', async () => {
const idb = installFakeIndexedDb();
try {
assert.equal(isIndexedDbAvailable(), true);
const db = await openAiSessionsIdb();
assert.equal(db.objectStoreNames.contains('sessions'), true);
assert.equal(db.objectStoreNames.contains('meta'), true);
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
const all = await idbGetAllSessions(db);
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
await idbDeleteSessionIds(db, ['chat-a']);
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
await idbClearSessions(db);
assert.deepEqual(await idbGetAllSessions(db), []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('meta store roundtrips schema and active-map records', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
// Mirror uses its own connection.
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
assert.equal(result.source, 'indexeddb');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
// Legacy key content was not consumed for a migration when IDB has data.
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
let readCount = 0;
const result = await loadOrMigrateAiSessions(() => {
readCount += 1;
return legacyRaw;
});
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
// Now in IndexedDB (one record per session).
assert.deepEqual(
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
['legacy-1', 'legacy-2'],
);
// Meta records written for the migration.
const db = await openAiSessionsIdb();
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
assert.ok(readCount >= 1);
// The legacy raw string itself is only read, never rewritten or removed
// — the callback is the only interface to the legacy store.
assert.equal(readCount >= 1, true);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => null);
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(result.idbFailed, false);
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
assert.equal(result.migrationPending, false);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
const idb = installFakeIndexedDb();
try {
const result = await loadOrMigrateAiSessions(() => '[]');
assert.equal(result.source, 'none');
assert.deepEqual(result.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
const idb = installFakeIndexedDb({ failOpen: true });
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, true);
assert.deepEqual(result.sessions, legacy);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
try {
const legacy = [makeSession('legacy-chat', 1)];
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
assert.equal(isIndexedDbAvailable(), false);
assert.equal(result.source, 'legacy-localstorage');
assert.equal(result.idbFailed, false);
assert.deepEqual(result.sessions, legacy);
} finally {
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
resetAiSessionsIdbForTests();
}
});
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
const idb = installFakeIndexedDb();
try {
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
const first = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(first.source, 'legacy-localstorage');
// The marker was persisted after the migration data became durable.
let db = await openAiSessionsIdb();
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
// The user deletes a chat; the legacy key stays behind by design.
await idbDeleteSessionIds(db, ['legacy-1']);
// Next boot: the marker is present, so the legacy key is never
// re-migrated and the deletion is not resurrected.
const second = await loadOrMigrateAiSessions(() => legacyRaw);
assert.equal(second.source, 'indexeddb');
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
db = await openAiSessionsIdb();
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
const idb = installFakeIndexedDb();
try {
// First boot with nothing anywhere records the marker.
const first = await loadOrMigrateAiSessions(() => null);
assert.equal(first.source, 'none');
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
// The user deleted every chat, but the stale legacy key is still there.
const second = await loadOrMigrateAiSessions(
() => JSON.stringify([makeSession('resurrected', 1)]),
);
assert.equal(second.source, 'none');
assert.equal(second.authoritativeEmpty, true);
assert.deepEqual(second.sessions, []);
assert.equal(idb.db.dumpStore('sessions').length, 0);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
const idb = installFakeIndexedDb();
try {
const primary = makeSession('primary', 3);
const diskA = makeSession('disk-a', 2);
const diskB = makeSession('disk-b', 1);
const diskIds = [diskA.id, diskB.id];
const db = await openAiSessionsIdb();
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
assert.equal(first.migrationPending, true);
assert.deepEqual(await idbGetAllSessions(db), [primary]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
await idbPutSession(db, updated);
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
assert.deepEqual(partial.sessions, [updated, diskA]);
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
assert.equal(complete.source, 'indexeddb');
assert.equal(complete.migrationPending, false);
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
await idbClearSessions(db);
const deleted = await loadOrMigrateAiSessions(() => {
assert.fail('completed migration must not retry the stale overflow markers');
}, diskIds);
assert.equal(deleted.authoritativeEmpty, true);
assert.deepEqual(deleted.sessions, []);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
const idb = installFakeIndexedDb();
try {
const db = await openAiSessionsIdb();
await idbPutSessions(db, [makeSession('already-here', 1)]);
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
assert.equal(result.source, 'indexeddb');
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(idb.db.dumpStore('sessions').length, 1);
} finally {
idb.restore();
resetAiSessionsIdbForTests();
}
});
+372
View File
@@ -0,0 +1,372 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* IndexedDB persistence for AI chat sessions (issue #4123).
*
* The legacy store kept every session as one JSON blob in localStorage under
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
* budget was exceeded the degradation ladder dropped whole oldest sessions —
* silently destroying capped chats (~1 MB each) on every write.
*
* IndexedDB removes both halves of that failure mode: records are written
* per session (no global re-serialization), and there is no shared byte
* quota to defend, so byte-driven eviction disappears entirely.
*
* Layout:
* - db `netcatty_ai_sessions`
* - object store `sessions` (keyPath `id`): one record per AISession
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
*
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
* one release as a read-only fallback; it is no longer written or pruned.
*/
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
export const AI_SESSIONS_IDB_VERSION = 1;
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
export const AI_SESSIONS_IDB_META_STORE = 'meta';
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
export function isIndexedDbAvailable(): boolean {
return typeof indexedDB !== 'undefined' && indexedDB !== null;
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
});
}
function transactionDone(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
});
}
let openPromise: Promise<IDBDatabase> | null = null;
function upgradeDatabase(db: IDBDatabase) {
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
}
}
export function openAiSessionsIdb(): Promise<IDBDatabase> {
if (!isIndexedDbAvailable()) {
return Promise.reject(new Error('IndexedDB is not available'));
}
if (!openPromise) {
openPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
request.onupgradeneeded = () => upgradeDatabase(request.result);
request.onsuccess = () => {
const db = request.result;
// Drop the cached handle so a later open() re-establishes it instead
// of handing out a closed connection.
db.onclose = () => { openPromise = null; };
db.onversionchange = () => {
db.close();
openPromise = null;
};
resolve(db);
};
request.onerror = () => {
openPromise = null;
reject(request.error ?? new Error('Failed to open IndexedDB'));
};
request.onblocked = () => {
openPromise = null;
};
});
}
return openPromise;
}
export function resetAiSessionsIdbForTests() {
openPromise = null;
}
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
const database = db ?? await openAiSessionsIdb();
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
const result = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
);
return Array.isArray(result) ? result : [];
}
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
if (sessions.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const session of sessions) store.put(session);
await transactionDone(transaction);
}
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
await idbPutSessions(db, [session]);
}
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
for (const id of ids) store.delete(id);
await transactionDone(transaction);
}
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
await transactionDone(transaction);
}
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
const record = await requestToPromise(
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
);
return record?.value;
}
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
await transactionDone(transaction);
}
// ── Cross-window change notification ──
let changeChannel: BroadcastChannel | null = null;
function getChangeChannel(): BroadcastChannel | null {
if (typeof BroadcastChannel === 'undefined') return null;
try {
if (!changeChannel) {
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
// The channel must never keep the process alive (Node exposes unref).
(changeChannel as unknown as { unref?: () => void }).unref?.();
}
return changeChannel;
} catch {
return null;
}
}
/** Announce a session-store change to OTHER windows (never the sender). */
export function publishAiSessionsIdbChange(): void {
try {
getChangeChannel()?.postMessage(Date.now());
} catch {
// Notification is best-effort; the write itself already succeeded.
}
}
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
const channel = getChangeChannel();
if (!channel) return () => {};
const handler = () => listener();
channel.addEventListener('message', handler);
return () => channel.removeEventListener('message', handler);
}
// ── Boot load + one-release legacy migration ──
export interface AiSessionsBootLoadResult {
sessions: AISession[];
/** Where the returned sessions came from. */
source: 'indexeddb' | 'legacy-localstorage' | 'none';
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
idbFailed: boolean;
/** The store was already initialized before this boot; empty means deleted. */
authoritativeEmpty: boolean;
/** Marked disk-only records are still missing; keep their backup generations. */
migrationPending: boolean;
}
/**
* Boot-time store resolution, shared with tests:
* 1. IndexedDB `getAll()` — the primary store.
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
* legacy key itself stays untouched as a read-only fallback for one
* release).
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
*
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
* marker is persisted in the meta store after the first successful boot
* resolution. Once present, the legacy key is never consulted again —
* otherwise a boot whose migration ran while the legacy key could not be
* removed would re-migrate the stale legacy list and resurrect deleted
* chats. The marker also distinguishes "intentionally empty" (the user
* deleted every chat) from "uninitialized".
*
* Callers decide what to do when both sources are empty (the #4018 backup
* generations remain the last restore step in useAIState).
* The legacy reader may await disk-only recovery before migration completes.
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
*/
export async function loadOrMigrateAiSessions(
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
diskOnlySessionIds: readonly string[] = [],
): Promise<AiSessionsBootLoadResult> {
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
const ids = new Set(sessions.map(session => session.id));
return diskOnlySessionIds.some(id => !ids.has(id));
};
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
const legacy = parseLegacySessions(await readLegacyRaw());
return {
sessions: legacy,
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed,
authoritativeEmpty: false,
migrationPending: hasUnresolvedDiskSessions(legacy),
};
};
if (!isIndexedDbAvailable()) {
return legacyFallback(false);
}
let db: IDBDatabase;
try {
db = await openAiSessionsIdb();
} catch (error) {
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let sessions: AISession[];
try {
sessions = await idbGetAllSessions(db);
} catch (error) {
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
return legacyFallback(true);
}
let marker: AiSessionsMigrationMarker | undefined;
try {
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
} catch {
marker = undefined;
}
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
if (!marker) {
// First boot on a store that already holds sessions (e.g. migrated by
// a build without the marker): record one-shot completion so the
// legacy key can never re-seed the store.
await writeMigrationMarker(db).catch(() => {});
}
return {
sessions,
source: sessions.length > 0 ? 'indexeddb' : 'none',
idbFailed: false,
authoritativeEmpty: sessions.length === 0,
migrationPending: false,
};
}
// Unfinished migration (empty or partially populated IDB). The legacy key is
// intentionally NOT removed: it stays as a read-only fallback for one
// release.
const legacy = parseLegacySessions(await readLegacyRaw());
// A partial migration may already have primary/chunk records in IDB. Only
// fill missing marked IDs; never replay stale primary data over live IDB.
const existingIds = new Set(sessions.map(session => session.id));
const diskOnlyIds = new Set(diskOnlySessionIds);
const recovered = sessions.length === 0
? legacy
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
const migrationPending = hasUnresolvedDiskSessions(merged);
const result: AiSessionsBootLoadResult = {
sessions: merged,
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
idbFailed: false,
authoritativeEmpty: false,
migrationPending,
};
if (merged.length === 0) {
// Nothing to migrate: mark the store initialized so this state is
// distinguishable from "uninitialized" on the next boot.
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
return result;
}
try {
await idbPutSessions(db, recovered);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
// Written AFTER the migration data is durable: a crash before this point
// leaves the marker absent and the next boot safely re-migrates.
if (!migrationPending) await writeMigrationMarker(db);
} catch (error) {
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
return { ...result, idbFailed: true };
}
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
return result;
}
interface AiSessionsMigrationMarker {
migrationCompleted: true;
migrationAt: number;
}
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
await idbPutMeta<AiSessionsMigrationMarker>(
db,
AI_SESSIONS_META_MIGRATION_KEY,
{ migrationCompleted: true, migrationAt: Date.now() },
);
}
function parseLegacySessions(raw: string | null | undefined): AISession[] {
if (raw == null || raw === '') return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
// ── Active-session map mirror (meta store) ──
/**
* Mirror the active-pointer map into the IndexedDB meta store. The
* localStorage key remains written as before (small, quota-safe) and stays
* the read-mostly authority for this release; the mirror makes the pointer
* available to future store work without another migration.
*/
export async function mirrorActiveSessionMapToIdb(
map: Record<string, string | null>,
): Promise<void> {
try {
const db = await openAiSessionsIdb();
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
} catch (error) {
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
}
}
/**
* Read the mirrored active-pointer map (boot restore aid when the
* localStorage key is missing).
*/
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
try {
const db = await openAiSessionsIdb();
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
return value && typeof value === 'object' ? value : null;
} catch {
return null;
}
}
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import {
DebouncedBackupMirror,
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
shouldRestoreFromBackup,
} from './aiSessionsBackup';
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
function makeSession(id: string, updatedAt = 1_000): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'workspace', targetId: 'ws-1' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as never;
}
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
assert.equal(isMissingOrCorruptSessionsStore(null), true);
assert.equal(isMissingOrCorruptSessionsStore(''), true);
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
});
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
for (const raw of [null, undefined, '', '{}', 'not-json']) {
assert.deepEqual(readLiveSessionsArray(raw), []);
}
const sessions = [makeSession('s1')];
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
});
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
assert.equal(parseBackupSessions(null), null);
assert.equal(parseBackupSessions(''), null);
assert.equal(parseBackupSessions('not json'), null);
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
const sessions = parseBackupSessions(JSON.stringify([
{ id: 's1' },
{ nope: true },
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
{ ...makeSession('null-scope'), scope: null },
{ ...makeSession('missing-title'), title: undefined },
{ ...makeSession('missing-agent'), agentId: undefined },
{ ...makeSession('null-message'), messages: [null] },
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
null,
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
]));
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
});
test('restore preserves all sessions when cloud sync is enabled', () => {
// Issue #4123: the retention cap is a session count (raised to 1000) —
// there is no byte budget any more.
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
const local = prepareRestoredSessions(sessions, false);
assert.equal(local.length, 1000);
assert.equal(local.some(session => session.id === 's0'), false);
});
test('malformed imports cannot reach pruning or inject broken sessions', () => {
const current = [makeSession('live')];
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
});
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
const backup = JSON.stringify([makeSession('s1')]);
// Empty (or wiped) store -> restore.
assert.equal(shouldRestoreFromBackup(null, backup), true);
assert.equal(shouldRestoreFromBackup('', backup), true);
assert.equal(shouldRestoreFromBackup('[]', backup), false);
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
// Non-empty live store -> never restore (explicit deletions must win).
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
// Empty/corrupt backup -> nothing to restore.
assert.equal(shouldRestoreFromBackup(null, null), false);
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
});
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
const current = [makeSession('keep', 100), makeSession('older', 100)];
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
const merged = mergeImportedSessions(current, imported);
const byId = new Map(merged.map((session) => [session.id, session]));
assert.equal(byId.get('older')?.updatedAt, 100);
assert.equal(byId.get('newer')?.updatedAt, 200);
assert.equal(byId.has('added'), true);
assert.equal(byId.has('keep'), true);
// Sorted newest-first for stable history ordering.
assert.deepEqual(
merged.map((session) => session.updatedAt),
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
);
});
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
mirror.schedule('{"gen":1}');
mirror.schedule('{"gen":2}');
await new Promise((resolve) => setTimeout(resolve, 30));
mirror.schedule('{"gen":3}');
mirror.schedule('{"gen":4}');
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
});
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
mirror.schedule('{"gen":1}');
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
// A second flush with nothing pending is a no-op.
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
});
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
assert.deepEqual(readLiveSessionsArray(null), []);
assert.deepEqual(readLiveSessionsArray(''), []);
assert.deepEqual(readLiveSessionsArray('{}'), []);
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
assert.deepEqual(readLiveSessionsArray('not-json'), []);
assert.deepEqual(readLiveSessionsArray('[]'), []);
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
});
+162
View File
@@ -0,0 +1,162 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* Renderer-side logic for the AI session durability layer (issue #4015 R2
* follow-up): a disk mirror of the localStorage session store plus a
* restore-on-empty-store boot path. The IPC plumbing lives in
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
* decisions pure so they can be tested without Electron.
*/
/** Debounce window: at most one backup write per mutation burst (~2s). */
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
export interface AiSessionsBackupBridge {
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
/**
* Parse the live sessions store once, returning both whether it is usable and
* its sessions. The history is often several MB; the helpers below used to
* parse it twice per call, and mount ran them back to back (five parses).
*/
export function parseSessionsStore(raw: string | null | undefined): { valid: boolean; sessions: AISession[] } {
if (raw == null || raw === '') return { valid: false, sessions: [] };
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? { valid: true, sessions: parsed as AISession[] }
: { valid: false, sessions: [] };
} catch {
return { valid: false, sessions: [] };
}
}
/** Restore only when the live store is missing or malformed. A valid empty
* array represents an intentional deletion and must remain authoritative. */
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
return !parseSessionsStore(raw).valid;
}
/** Read the live sessions array from a raw localStorage string. Returns [] when
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
* treat a non-array as AISession[]. */
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
return parseSessionsStore(raw).sessions;
}
/**
* Parse a backup payload into sessions. Returns null when the payload is
* corrupt or malformed (ignored gracefully); also drops entries without a
* usable required fields. Backup generations are written by the same serializer as the
* live store, so entries are expected to be full AISession objects.
*/
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
if (typeof json !== 'string' || json === '') return null;
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const sessions = parsed.filter(
(entry): entry is AISession => (
typeof entry === 'object' && entry !== null
&& typeof (entry as AISession).id === 'string'
&& typeof (entry as AISession).title === 'string'
&& typeof (entry as AISession).agentId === 'string'
&& typeof (entry as AISession).scope === 'object'
&& (entry as AISession).scope !== null
&& typeof (entry as AISession).scope.type === 'string'
&& Array.isArray((entry as AISession).messages)
&& (entry as AISession).messages.every(message => (
typeof message === 'object' && message !== null
&& typeof message.id === 'string'
&& typeof message.role === 'string'
&& typeof message.content === 'string'
&& typeof message.timestamp === 'number'
&& Number.isFinite(message.timestamp)
))
&& typeof (entry as AISession).updatedAt === 'number'
&& Number.isFinite((entry as AISession).updatedAt)
),
);
return sessions.length > 0 ? sessions : null;
}
/**
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
* the backup actually contains at least one session.
*/
export function shouldRestoreFromBackup(
liveRaw: string | null | undefined,
backupJson: string | null | undefined,
): boolean {
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
const sessions = parseBackupSessions(backupJson);
return sessions != null && sessions.length > 0;
}
/**
* Merge an imported backup into the current store: entries are keyed by id
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
* the live store (unlike the boot restore, which is empty-only).
*/
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
const byId = new Map<string, AISession>();
for (const session of current) byId.set(session.id, session);
for (const session of imported) {
const existing = byId.get(session.id);
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
byId.set(session.id, session);
}
}
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
/**
* Debounced mirror scheduler. One rolling timer per process: the last
* mutation batch wins, and the write happens `debounceMs` after the batch
* settles (at most once per batch, per the durability spec).
*/
export class DebouncedBackupMirror {
private timer: ReturnType<typeof setTimeout> | null = null;
private pendingJson: string | null = null;
constructor(
private readonly write: (json: string) => void,
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
) {}
schedule(json: string) {
this.pendingJson = json;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}, this.debounceMs);
}
flushNow() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}
reset() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pendingJson = null;
}
}
File diff suppressed because it is too large. Load diff
+356 -343
View File
@@ -2,8 +2,13 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import { import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP, STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys'; } from '../../infrastructure/config/storageKeys';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import type { import type {
AIDraft, AIDraft,
AIPanelView, AIPanelView,
@@ -11,7 +16,6 @@ import type {
AIPermissionMode, AIPermissionMode,
AIToolIntegrationMode, AIToolIntegrationMode,
} from '../../infrastructure/ai/types'; } from '../../infrastructure/ai/types';
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction'; import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
import { import {
bumpDraftMutationVersionState, bumpDraftMutationVersionState,
@@ -20,14 +24,26 @@ import {
} from './aiDraftState'; } from './aiDraftState';
import { import {
pruneInactiveScopedSessions, pruneInactiveScopedSessions,
pruneInactiveScopedTransientState, pruneInactiveScopedState,
} from './aiScopeCleanup'; } from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents'; import { emitAIStateChanged } from './aiStateEvents';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime'; import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
import {
idbDeleteSessionIds,
idbPutMeta,
idbPutSessions,
isIndexedDbAvailable,
openAiSessionsIdb,
publishAiSessionsIdbChange,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */ /** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge { export interface AIBridge {
aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>; aiSdkAgentCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiPrivacyClearSession?: (chatSessionId: string) => Promise<{ ok: boolean; error?: string }>;
deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>; deleteChatToolOutputsTemp?: (chatSessionId: string) => Promise<{ deletedCount: number }>;
deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>; deleteTerminalToolOutputsEverywhereTemp?: (terminalSessionId: string) => Promise<{ deletedCount: number }>;
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown; aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
@@ -35,6 +51,11 @@ export interface AIBridge {
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown; aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown; aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown; aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
/** Durability mirror: write one timestamped backup generation to disk. */
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
} }
export function getAIBridge() { export function getAIBridge() {
@@ -54,11 +75,29 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {}))); await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
} }
/**
* Session ids explicitly deleted by the user (or by an authoritative cloud
* merge). Their disappearance from the persisted set must not trigger the
* pre-eviction backup generation — only cap-driven drops should.
*/
const intentionallyDeletedSessionIds = new Set<string>();
export function cleanupDeletedAIChatSessions(sessionIds: string[]) { export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
const bridge = getAIBridge(); const bridge = getAIBridge();
if (sessionIds.length === 0) return; if (sessionIds.length === 0) return;
// Only the startup-light handle is imported here; the runtime registers it
// when created. Without a runtime there is no in-memory runtime state, but
// the reversible-privacy flag (set outside the runtime) and the
// main-process privacy mapping may still exist, so always clear those.
const runtime = peekAgentRuntimeCleanup();
for (const sessionId of sessionIds) { for (const sessionId of sessionIds) {
getAgentRuntime().clearChatSession(sessionId); intentionallyDeletedSessionIds.add(sessionId);
if (runtime) {
runtime.clearChatSession(sessionId);
} else {
setReversibleModelPrivacy(sessionId, false);
void bridge?.aiPrivacyClearSession?.(sessionId).catch(() => {});
}
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {}); void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {}); void bridge?.deleteChatToolOutputsTemp?.(sessionId).catch(() => {});
} }
@@ -66,8 +105,9 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) { export function cleanupClosedTerminalSessions(terminalSessionIds: string[]) {
const bridge = getAIBridge(); const bridge = getAIBridge();
const runtime = peekAgentRuntimeCleanup();
for (const terminalSessionId of new Set(terminalSessionIds)) { for (const terminalSessionId of new Set(terminalSessionIds)) {
getAgentRuntime().clearTerminalSession(terminalSessionId); runtime?.clearTerminalSession(terminalSessionId);
void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {}); void bridge?.deleteTerminalToolOutputsEverywhereTemp?.(terminalSessionId).catch(() => {});
} }
} }
@@ -82,10 +122,16 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
return activeTargetIds.has(targetId); return activeTargetIds.has(targetId);
} }
/** Last live terminal/workspace target ids observed by orphan cleanup. */
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) { export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot latestAIActiveTargetIdsSnapshot = activeTargetIds;
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) // Child maintenance effects run before the provider's boot effects. Keep
?? []; // the live targets for post-hydration cleanup without adopting stale legacy
// history as authoritative state while IndexedDB is still loading.
if (!isAiSessionsHydrationComplete()) return;
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
// Sessions shown by a still-live scope must be protected from cleanup // Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal — // even when their own `scope.targetId` points at a closed terminal —
@@ -111,224 +157,114 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds); cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
} }
if (nextSessionCleanup.sessions !== currentSessions) { // Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
setLatestAISessionsSnapshot(nextSessionCleanup.sessions); // instead of stripping its record or pointer. `/chats` lists dangling chats
writeSessionsForStorage(nextSessionCleanup.sessions); // with a rebind affordance; the flag clears when the chat is rebound or its
// scope target becomes live again.
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
let sessionsMarked = false;
const markedSessions = currentSessions.map((session) => {
if (orphanedIds.has(session.id)) {
if (session.scopeDangling) return session;
sessionsMarked = true;
return { ...session, scopeDangling: true };
}
if (
session.scopeDangling
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
) {
sessionsMarked = true;
const { scopeDangling: _cleared, ...rest } = session;
return rest as AISession;
}
return session;
});
if (sessionsMarked) {
setLatestAISessionsSnapshot(markedSessions);
writeSessionsForStorage(markedSessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS); emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
} }
const activeSessionIdMap = preCleanupActiveSessionMap; // Issue #4015 R2: keep active-pointer entries for scopes whose target
let activeSessionMapChanged = false; // closed. Clearing them used to make dangling chats invisible (rank 0) and
const nextActiveSessionIdMap = { ...activeSessionIdMap }; // unrecoverable from the UI; the pointer is rewritten on rebind instead.
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {}; const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {}; const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState( const prunedScopedState = pruneInactiveScopedState(
currentActiveSessionIdMap,
currentDraftsByScope, currentDraftsByScope,
currentPanelViewByScope, currentPanelViewByScope,
activeTargetIds, activeTargetIds,
); );
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) { if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) { for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue; if (scopeKey in prunedScopedState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey); bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey); bumpDraftUploadGeneration(scopeKey);
} }
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope); setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE); emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
} }
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) { if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) { for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue; if (scopeKey in prunedScopedState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey); bumpDraftMutationVersion(scopeKey);
} }
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope); setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE); emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
} }
} }
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/** /**
* Byte budget for the serialized sessions JSON. The localStorage quota is * Maximum number of sessions kept in the persisted store. IndexedDB has no
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens * shared byte quota, so retention is a plain count cap; the cap only exists
* of KB per turn, so keep the sessions blob well under the quota with * to keep history from growing without bound.
* headroom for the rest of the app's storage keys.
*/ */
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024; const MAX_STORED_SESSIONS = 1000;
/** Retry budgets used when the primary budget still fails to persist. */
const RETRY_SESSIONS_JSON_BYTES = [
1024 * 1024,
512 * 1024,
256 * 1024,
128 * 1024,
] as const;
/** /**
* Maximum persisted length of a single tool result payload. Terminal tool * Per-session message cap, read from user settings (clamped to 50–2000,
* output can reach hundreds of KB per command, and AI-driven terminal work * default 200). Read on every write so a lowered cap trims the oldest
* accumulates many of them. When storage pressure forces degradation, shedding * messages on the next write; a raised cap stops further trimming.
* this replay bulk keeps every visible conversation persisted instead of * Best-effort: an unavailable storage layer keeps the default cap.
* evicting whole chats that happen to carry it.
*/ */
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024; export function readAiSessionMessageCap(): number {
try {
function truncatePersistedToolResultContent(content: string): string { return normalizeAiSessionMessageCap(
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content; localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
// The marker consumes budget and must stay single-line: JSON-escaped ?? DEFAULT_AI_SESSION_MESSAGE_CAP,
// newlines would cost 2 characters each, letting a slightly-over-cap );
// result serialize at the same (or larger) size than the original — the } catch {
// ladder assumes the degraded form is the smallest. Shrink the retained return DEFAULT_AI_SESSION_MESSAGE_CAP;
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
} }
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
} }
/** /**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted * Prune sessions before writing to the persisted store to bound retention.
* continuation and shed bulk tool output. The ciphertext exists so stateless * Only affects what is persisted — the in-memory state retains all messages
* Responses turns can replay prior reasoning items; tool output dominates * until the session is reloaded.
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
): ProviderContinuationOptions | undefined {
const hasCiphertext = Object.values(options).some(
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
);
if (!hasCiphertext) return options;
const stripped: ProviderContinuationOptions = {};
for (const [provider, providerOptions] of Object.entries(options)) {
const rest = { ...providerOptions };
delete rest.reasoningEncryptedContent;
if (Object.keys(rest).length) stripped[provider] = rest;
}
return Object.keys(stripped).length ? stripped : undefined;
}
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
const parts = continuation.reasoningParts.map(part => {
if (!part.providerOptions) return part;
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
if (providerOptions === part.providerOptions) return part;
changed = true;
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
});
if (!changed) return message;
return {
...message,
providerContinuation: {
...continuation,
reasoningParts: parts,
},
};
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
const compactedMessageCount = Math.min(
session.messages.length,
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
);
if (compactedMessageCount === 0) return session;
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripReasoningCiphertextFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
return changed ? { ...session, messages } : session;
}
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
* *
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions. * - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES. * - Trims each session's messages to the last `messageCap` messages.
*/ */
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] { export function pruneSessionsForStorage(
sessions: AISession[],
messageCap: number = readAiSessionMessageCap(),
): AISession[] {
// Sort by updatedAt descending so we keep the newest // Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt); const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS); const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => { return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) { if (s.messages.length > messageCap) {
// Do not start the retained tail with a tool result whose assistant call // Do not start the retained tail with a tool result whose assistant call
// was just trimmed. Reuse the same tool-safe boundary logic as context // was just trimmed. Reuse the same tool-safe boundary logic as context
// compaction, even if that retains a few more than the nominal cap. // compaction, even if that retains a few more than the nominal cap.
const removedMessageCount = findSafeChatMessageCompactionSplitIndex( const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
s.messages, s.messages,
MAX_SESSION_MESSAGES, messageCap,
); );
const contextCompaction = s.contextCompaction const contextCompaction = s.contextCompaction
? { ? {
@@ -349,179 +285,234 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
}); });
} }
/** export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
* Serialize sessions for localStorage under a byte budget, escalating pruning return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
* as needed. Returns the JSON to persist plus the (possibly) further-pruned }
* sessions that JSON represents.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
): { json: string; sessions: AISession[] } {
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
// These messages are replaced by the durable summary before every later
// request, so their replay ciphertext can never be used again. Remove it
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
return { session, json: JSON.stringify(session) };
});
// Check whether retained history fits before preparing degradation. Use // ── IndexedDB persistence (issue #4123) ──
// the existing JSON string-length budget including array delimiters, and //
// avoid preparing unused stripped messages and serializing payloads again. // Sessions are persisted one record per session. There is no global byte
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0) // budget and no re-serialization ladder: the 2 MB localStorage budget existed
+ Math.max(0, prepared.length - 1); // to defend a shared quota against the cost of serializing the whole store on
if (fullLength <= budgetBytes) { // every write, and both are gone with per-record writes. Only a count cap
return { // (MAX_STORED_SESSIONS) and the per-session message cap remain.
json: `[${prepared.map(entry => entry.json).join(',')}]`,
sessions: prepared.map(entry => entry.session),
};
}
// Reuse the full representation when the unchanged degradation ladder is /** Last set of sessions known to be persisted, for per-record diffing. */
// needed, including on quota retries with a smaller budget. let lastPersistedSessionsById: Map<string, AISession> = new Map();
const serialized = prepared.map(({ session, json }) => { /** Serialized write queue: IDB operations never interleave. */
const degradableMessages = session.messages.flatMap((message, index) => { let idbWriteQueue: Promise<void> = Promise.resolve();
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
return {
session,
json,
strippedSession,
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
};
});
// Preserve the newest session's full continuation whenever it can fit by /** Test hook: reset diff state between tests. */
// itself. That session is the one the user is most likely continuing now; export function __resetIdbPersistenceForTests() {
// older visible history must not make its next tool turn unreplayable. lastPersistedSessionsById = new Map();
const protectNewestContinuation = serialized.length > 0 idbWriteQueue = Promise.resolve();
&& serialized[0].json.length + 2 <= budgetBytes; // Tests swap the fake indexedDB factory between cases; the cached open
// handle must not survive the swap.
resetAiSessionsIdbForTests();
// Tests simulate the post-boot state by default; the boot-gating cases
// call __resetAiSessionsHydrationForTests() to go back to pending.
markAiSessionsHydrationComplete();
}
// Determine how many sessions can fit using the smallest representation for /** Test hook: resolve once every queued IndexedDB sync has settled. */
// older sessions while reserving the newest session's full representation export function __flushIdbWritesForTests(): Promise<void> {
// when possible. This also prevents an old, oversized visible chat that must return idbWriteQueue.then(() => undefined, () => undefined);
// be dropped anyway from causing newer replay ciphertext to be stripped. }
let minimalLength = 2
+ serialized.reduce((total, entry, index) => (
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
), 0)
+ Math.max(0, serialized.length - 1);
while (minimalLength > budgetBytes && serialized.length > 1) {
const removed = serialized.pop();
if (!removed) break;
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and shed // ── Boot hydration signal (issue #4125 review) ──
// replay-only bulk (ciphertext, oversized tool output) from the oldest //
// retained sessions only as needed, never touching the protected newest // Settles once useAIState's boot load has finished (IndexedDB read, legacy
// session. Within one session, shed it one message at a time from oldest to // migration, disk-backup restore). Sync collection and the active-pointer
// newest so a long active chat can retain its most recent replayable tool // validation must not run before it: a pre-hydration collect reads the
// exchange when that still fits. // stale legacy localStorage store and would overwrite newer IndexedDB
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0) // records, and pointer validation against the initially-empty in-memory
+ Math.max(0, serialized.length - 1); // array would null every pointer before the real sessions arrive.
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
for (
let index = serialized.length - 1;
index >= firstStrippableIndex && jsonLength > budgetBytes;
index -= 1
) {
const current = serialized[index];
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
...current,
session: nextSession,
json: nextJson,
};
}
}
return { let aiSessionsHydrationComplete = false;
json: `[${serialized.map(entry => entry.json).join(',')}]`,
sessions: serialized.map(entry => entry.session), export function isAiSessionsHydrationComplete(): boolean {
}; return aiSessionsHydrationComplete;
}
/** Called by useAIState's boot effect when the boot load settles. */
export function markAiSessionsHydrationComplete(): void {
aiSessionsHydrationComplete = true;
}
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
export function __resetAiSessionsHydrationForTests(): void {
aiSessionsHydrationComplete = false;
}
/** Seed the diff baseline after a boot load or migration. */
export function seedLastPersistedSessions(sessions: AISession[]) {
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
} }
/** /**
* Persist sessions to localStorage with byte-budgeted pruning and retries. * Read the persisted session set synchronously. Source of truth is the
* Returns true when the write succeeded; a false result means the payload * in-memory snapshot held by useAIState; this accessor returns the last set
* could not be persisted even after escalation (it stays memory-only). * handed to `writeSessionsForStorage`, falling back to the read-only legacy
* localStorage store (kept untouched for one release).
*/ */
export function writeSessionsForStorage(sessions: AISession[]): boolean { export function getPersistedAiSessionsSnapshot(): AISession[] {
// Synced records are authoritative after restart: silently evicting a if (lastPersistedSessionsById.size > 0) {
// session or turn here would turn local retention into cloud deletion. return Array.from(lastPersistedSessionsById.values())
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) { .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
} }
let previousLength = Number.POSITIVE_INFINITY; return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
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
// the failed payload was already below the nominal retry budget. Reduce function queueIdbSessionSync(
// materially on each bounded retry so several small sessions can be changed: AISession[],
// removed before the retry sequence is exhausted. removedIds: string[],
const reducedBudget = Number.isFinite(previousLength) prevById: Map<string, AISession>,
? Math.floor(previousLength * 0.75) persisted: AISession[],
: configuredBudget; ) {
const budget = Math.min(configuredBudget, reducedBudget); if (changed.length === 0 && removedIds.length === 0) return;
if (budget < 2) break; idbWriteQueue = idbWriteQueue
const candidate = serializeSessionsForStorage(sessions, budget); .then(async () => {
if (candidate.json.length >= previousLength) continue; if (!isIndexedDbAvailable()) return;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true; const db = await openAiSessionsIdb();
previousLength = candidate.json.length; // Per-record write: only the changed sessions are put, only removed
// ids are deleted.
if (changed.length > 0) await idbPutSessions(db, changed);
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
await idbPutMeta(db, 'schemaVersion', 1);
await idbPutMeta(db, 'lastWriteAt', Date.now());
publishAiSessionsIdbChange();
})
.catch((error) => {
// IndexedDB failures must never break the in-memory session state.
// Reset the diff baseline so the next write retries every record, and
// fall back to the legacy localStorage key so a corrupt/unavailable
// IndexedDB still leaves a restorable trace (via the disk mirror).
console.warn('[AIState] IndexedDB session sync failed:', error);
lastPersistedSessionsById = new Map(prevById);
try {
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
} catch {
// Legacy fallback is best-effort too.
}
});
}
// Durability mirror (issue #4015 follow-up): every write also schedules a
// debounced disk backup so a wiped/recreated profile does not take the chat
// history with it. The persisted JSON is mirrored; when the count cap evicts
// sessions, a PRE-eviction generation is written first so evicted chats stay
// recoverable from disk (the blind spot found in issue #4123).
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
} catch {
// Backup failures must never break chat persistence.
} }
// Last resort: attempt the smallest representation this serializer can });
// produce (the newest session with replay-only ciphertext removed). A very
// small amount of shared quota may still be enough to preserve that chat. let aiSessionsBackupRecoveryPending = false;
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
if ( /** Partial migration must not rotate away generations holding disk-only chats. */
minimalCandidate.json.length < previousLength export function setAiSessionsBackupRecoveryPending(pending: boolean) {
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json) aiSessionsBackupRecoveryPending = pending;
) { if (pending) backupMirror.reset();
}
export function flushBackupMirror() {
try {
backupMirror.flushNow();
} catch {
// Unload and persistence must remain fail-safe.
}
}
export function __resetBackupMirrorForTests() {
backupMirror.reset();
aiSessionsBackupRecoveryPending = false;
}
export function scheduleBackupMirror(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
} catch {
// ignore
}
}
/** Write one immediate backup generation (bypasses the debounce window). */
function writeImmediateBackupGeneration(json: string) {
if (aiSessionsBackupRecoveryPending) return;
try {
backupMirror.schedule(json);
backupMirror.flushNow();
} catch {
// Backup failures must never break chat persistence.
}
}
/**
* Perf (#4121): the exact same immutable sessions array is often written
* again by the debounced save path and by cloud-sync collection; skipping
* the re-stringify for a reference-identical set keeps that cheap. The
* per-record IDB diff below already makes the store write itself cheap.
*/
let lastMirroredSessions: AISession[] | null = null;
/**
* Persist sessions. Writes go to IndexedDB, one record per session, through a
* serialized queue; errors are logged, never thrown. Returns true when the
* persisted set was computed (in-memory state remains authoritative either
* way).
*/
export function writeSessionsForStorage(
sessions: AISession[],
options?: { deletedSessionIds?: readonly string[] },
): boolean {
try {
// Explicitly deleted ids never count as cap eviction (they also must not
// trigger a pre-eviction backup generation).
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
// Synced records are authoritative after restart: never prune them, or
// local retention would turn into cloud deletion on the next sync.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
)?.enabled;
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
const prevById = lastPersistedSessionsById;
const persistedIds = new Set(persisted.map(session => session.id));
// Count-cap eviction: sessions that drop out of the persisted set without
// being explicitly deleted get a pre-eviction disk generation containing
// the full pre-eviction store, so they remain recoverable.
const evicted = Array.from(prevById.entries())
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
.map(([, session]) => session);
intentionallyDeletedSessionIds.clear();
if (evicted.length > 0) {
writeImmediateBackupGeneration(JSON.stringify(sessions));
console.warn(
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
);
}
// Per-record diff (synchronous: the baseline must advance even when
// IndexedDB is unavailable, and callers read the snapshot right after).
const changed = persisted.filter(session => prevById.get(session.id) !== session);
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
scheduleBackupMirror(JSON.stringify(persisted));
lastMirroredSessions = persisted;
}
queueIdbSessionSync(changed, removedIds, prevById, persisted);
return true; return true;
} catch (error) {
console.warn('[AIState] Failed to persist AI sessions:', error);
return false;
} }
console.warn(
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
);
return false;
} }
export let latestAISessionsSnapshot: AISession[] | null = null; export let latestAISessionsSnapshot: AISession[] | null = null;
@@ -531,6 +522,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {}; let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {}; let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
/**
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
* chat. Drops every pointer that still names `sessionId` under a different
* scope key, then sets the new scope as active. Callers must persist and
* publish the returned map (do not rebuild from a stale snapshot).
*/
export function rebindActiveSessionMap(
activeSessionIdMap: Record<string, string | null>,
sessionId: string,
scopeKey: string,
): Record<string, string | null> {
let next = activeSessionIdMap;
for (const [key, id] of Object.entries(activeSessionIdMap)) {
if (id !== sessionId || key === scopeKey) continue;
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
delete next[key];
}
if (next[scopeKey] === sessionId) return next;
return { ...next, [scopeKey]: sessionId };
}
export function setLatestAISessionsSnapshot(sessions: AISession[]) { export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions; latestAISessionsSnapshot = sessions;
} }
@@ -541,10 +554,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
export function prewarmAIStateStorageSnapshots() { export function prewarmAIStateStorageSnapshots() {
try { try {
if (latestAISessionsSnapshot === null) { // Sessions are no longer prewarmed from localStorage: the primary store is
latestAISessionsSnapshot = // IndexedDB and loads asynchronously in useAIState. Prewarming the stale
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []; // read-only legacy key would make the boot loader skip the IndexedDB
} // contents ("never restore over non-empty live state").
if (latestAIActiveSessionMapSnapshot === null) { if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot = latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}; localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
@@ -1,5 +1,6 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { HistoryEntry } from "./commandHistoryStore";
import { import {
STORAGE_KEY_COMMAND_HISTORY, STORAGE_KEY_COMMAND_HISTORY,
@@ -73,7 +74,9 @@ const {
getCommandHistoryClearGeneration, getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared, hasCommandHistoryBeenCleared,
markCommandHistoryCleared, markCommandHistoryCleared,
fuzzyQueryHistory,
queryHistory, queryHistory,
queryRecentHistoryByCommand,
recordCommand, recordCommand,
subscribeCommandHistoryCleared, subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts"); } = await import("./commandHistoryStore.ts");
@@ -326,3 +329,172 @@ test("an external clear also drops in-memory compose-bar recall", async () => {
unsubscribe(); unsubscribe();
}); });
test("saving after a command and peer writes read the clear generation without parsing the history", async (t) => {
clearHistory();
recordCommand("echo fast-path", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
assert.match(raw, /^\{"clearGeneration":\d+,/, "generation is serialized first");
const parse = t.mock.method(JSON, "parse");
recordCommand("echo fast-path-2", "host-fast", "linux");
assert.equal(flushCommandHistoryStore(), true);
// A peer window's save only needs the generation as well.
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, storage.get(COMMAND_HISTORY_STORAGE_KEY)!, raw);
assert.equal(parse.mock.callCount(), 0);
parse.mock.restore();
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
});
const QUERY_NOW = 1_700_000_000_000;
const DAY_MS = 24 * 60 * 60 * 1000;
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
command, hostId: "host-1", os: "linux", frequency: 1,
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
};
}
function installHistoryEntries(entries: HistoryEntry[]): void {
clearHistory();
const raw = JSON.stringify({
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
}
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vim duplicate"),
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("Vim case"),
historyEntry("vim tied"),
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
historyEntry("unrelated", { frequency: 100 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
assert.deepEqual(queryHistory("missing"), []);
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
});
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vim a", { frequency: 2 }),
historyEntry("vim b", { frequency: 3 }),
historyEntry("vim c", { frequency: 4 }),
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
});
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vi alpha"),
historyEntry("vi beta"),
historyEntry("very indirect", { frequency: 2 }),
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
historyEntry("iv does not match"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory(""), []);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
});
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
const entries = [
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("cd '/work/new folder'"),
historyEntry("CD /work/tied"),
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
];
installHistoryEntries(entries);
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
});
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("av-x"),
historyEntry("avx"),
historyEntry(`v${"a".repeat(1900)}x`),
historyEntry("v-x"),
historyEntry("v/x"),
historyEntry("v_x"),
historyEntry("v x"),
historyEntry("vx adjacent"),
historyEntry("v missing second character"),
historyEntry("xv wrong order"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vx"), [
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
]);
assert.deepEqual(fuzzyQueryHistory("vv"), []);
});
for (const size of [4_000, 40_000]) {
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
// Unsorted scores/timestamps, with the best match at the end of the store.
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
}));
installHistoryEntries(entries);
queryHistory("missing"); // Warm the cache before measuring query operations.
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
const pow = t.mock.method(Math, "pow");
const lower = t.mock.method(String.prototype, "toLowerCase");
// node:test cannot mock methods directly on arrays (including Array.prototype).
const originalSort = Array.prototype.sort;
const sortedSizes: number[] = [];
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
sortedSizes.push(this.length);
return originalSort.call(this, compare);
};
t.after(() => { Array.prototype.sort = originalSort; });
const queries = [
() => queryHistory("vi", { limit: 5 }),
() => fuzzyQueryHistory("vi", { limit: 5 }),
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
];
for (const query of queries) {
const beforePow = pow.mock.callCount();
const beforeLower = lower.mock.callCount();
const result = query();
assert.deepEqual(result, expected);
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
}
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
});
}
+126 -103
View File
@@ -68,33 +68,75 @@ function loadStore(): HistoryStore {
} }
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveIdleHandle: number | null = null;
const CLEAR_GENERATION_PREFIX = /^\{"clearGeneration":(\d+)[,}]/;
/**
* The store's clear generation, read from its raw JSON without parsing the
* whole history (up to 10k entries, ~1.5 MB). Stores are serialized with
* clearGeneration first (serializeStore); other layouts fall back to a parse.
*/
function readRawClearGeneration(raw: string | null): number | null {
if (!raw) return null;
const match = CLEAR_GENERATION_PREFIX.exec(raw.slice(0, 48));
if (match) return Number(match[1]);
try {
return readClearGeneration(JSON.parse(raw) as HistoryStore);
} catch {
return null;
}
}
function serializeStore(store: HistoryStore): HistoryStore {
// Keep clearGeneration first so readRawClearGeneration can find it.
return { clearGeneration: readClearGeneration(store), version: store.version, entries: store.entries };
}
function persistStoreNow(store: HistoryStore): boolean { function persistStoreNow(store: HistoryStore): boolean {
// A peer clear is visible in storage before its queued event is handled. // 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. // Never flush a cached pre-clear snapshot over that newer empty store.
const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY); // Only the generation is needed on this per-command path; parse the stored
if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) { // history just in the rare case where it must be adopted.
cachedStore = persisted; const persistedGeneration = readRawClearGeneration(localStorageAdapter.readString(STORAGE_KEY));
knownClearGeneration = readClearGeneration(persisted); if (persistedGeneration !== null && persistedGeneration > readClearGeneration(store)) {
notifyCommandHistoryCleared(); const persisted = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
return true; if (persisted && readClearGeneration(persisted) > readClearGeneration(store)) {
cachedStore = persisted;
knownClearGeneration = readClearGeneration(persisted);
notifyCommandHistoryCleared();
return true;
}
} }
const ok = localStorageAdapter.write(STORAGE_KEY, store); const ok = localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
if (ok) return true; if (ok) return true;
// Storage full — evict lowest scored entries (not just oldest by insertion) // Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now(); const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now)); store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2)); store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
return localStorageAdapter.write(STORAGE_KEY, store); return localStorageAdapter.write(STORAGE_KEY, serializeStore(store));
}
function hasPendingSave(): boolean {
return saveTimer !== null || saveIdleHandle !== null;
} }
function saveStore(store: HistoryStore): void { function saveStore(store: HistoryStore): void {
cachedStore = store; cachedStore = store;
// Debounce saves to avoid excessive writes // Debounce saves, then write when the renderer is idle: serializing the
if (saveTimer) clearTimeout(saveTimer); // history right after Enter used to land in the middle of command output.
cancelPendingSave();
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
persistStoreNow(store);
saveTimer = null; saveTimer = null;
const persist = () => {
saveIdleHandle = null;
persistStoreNow(store);
};
if (typeof requestIdleCallback === "function") {
saveIdleHandle = requestIdleCallback(persist, { timeout: 2000 });
} else {
persist();
}
}, 500); }, 500);
} }
@@ -103,6 +145,10 @@ function cancelPendingSave(): void {
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = null; saveTimer = null;
} }
if (saveIdleHandle !== null) {
if (typeof cancelIdleCallback === "function") cancelIdleCallback(saveIdleHandle);
saveIdleHandle = null;
}
} }
/** /**
@@ -129,26 +175,14 @@ function applyExternalCommandHistoryWrite(
rawNewValue: string | null, rawNewValue: string | null,
rawOldValue: string | null, rawOldValue: string | null,
): void { ): void {
let incomingGeneration: number | null = null; // Fires in every other window for each save; read just the generation.
if (rawNewValue) { const incomingGeneration = readRawClearGeneration(rawNewValue);
try {
incomingGeneration = readClearGeneration(JSON.parse(rawNewValue) as HistoryStore);
} catch {
incomingGeneration = null;
}
}
let previousGeneration = knownClearGeneration; let previousGeneration = knownClearGeneration;
if (previousGeneration === null) { if (previousGeneration === null) {
// A fresh popup can have compose recall before its first autocomplete query. // 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. // The event's old value is its baseline, even after an earlier global clear.
try { previousGeneration = readRawClearGeneration(rawOldValue) ?? 0;
previousGeneration = rawOldValue
? readClearGeneration(JSON.parse(rawOldValue) as HistoryStore)
: 0;
} catch {
previousGeneration = 0;
}
} }
if (incomingGeneration !== null && incomingGeneration > previousGeneration) { if (incomingGeneration !== null && incomingGeneration > previousGeneration) {
cancelPendingSave(); cancelPendingSave();
@@ -156,7 +190,7 @@ function applyExternalCommandHistoryWrite(
knownClearGeneration = incomingGeneration; knownClearGeneration = incomingGeneration;
notifyCommandHistoryCleared(); notifyCommandHistoryCleared();
} else { } else {
if (!saveTimer) cachedStore = null; if (!hasPendingSave()) cachedStore = null;
if (incomingGeneration !== null && knownClearGeneration === null) { if (incomingGeneration !== null && knownClearGeneration === null) {
knownClearGeneration = incomingGeneration; knownClearGeneration = incomingGeneration;
} }
@@ -318,6 +352,36 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
argumentPrefix?: string; argumentPrefix?: string;
} }
interface RankedHistoryEntry {
entry: HistoryEntry;
score: number;
}
/**
* Keep only the best k distinct commands. Queries score each match once and
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
* for autocomplete). Entries arrive in store order for stable score ties.
*/
function retainHistoryMatch(
best: RankedHistoryEntry[],
entry: HistoryEntry,
score: number,
limit: number,
): void {
if (best.length >= limit && score <= best[best.length - 1].score) return;
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
if (duplicate >= 0) {
if (score <= best[duplicate].score) return;
best.splice(duplicate, 1);
}
// Insert after equal scores to preserve the original store order on ties.
const index = best.findIndex((candidate) => score > candidate.score);
if (best.length >= limit) best.pop();
best.splice(index < 0 ? best.length : index, 0, { entry, score });
}
/** /**
* Query history entries matching a prefix. * Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency). * Returns entries sorted by relevance (frequency * recency).
@@ -332,33 +396,17 @@ export function queryHistory(
const lowerPrefix = prefix.toLowerCase(); const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query const now = Date.now(); // Cache once per query
const filtered = store.entries.filter((entry) => { const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
// Must match prefix // Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false; if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
// Must not be identical to prefix // Must not be identical to prefix
if (entry.command === prefix) return false; if (entry.command === prefix) continue;
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
} }
return results; return best.map(({ entry }) => entry);
} }
/** /**
@@ -376,7 +424,7 @@ export function fuzzyQueryHistory(
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query const now = Date.now(); // Cache once per query
const scored: { entry: HistoryEntry; matchScore: number }[] = []; const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) { for (const entry of store.entries) {
// Host filtering // Host filtering
@@ -386,24 +434,11 @@ export function fuzzyQueryHistory(
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase()); const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) { if (matchScore > 0 && entry.command !== query) {
scored.push({ entry, matchScore }); retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
} }
} }
scored.sort((a, b) => return best.map(({ entry }) => entry);
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
} }
/** /**
@@ -428,38 +463,26 @@ export function queryRecentHistoryByCommand(
const commandPrefix = `${trimmedCommandName} `; const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? ""); const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const filtered = store.entries.filter((entry) => { const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const lowerCommand = entry.command.toLowerCase(); const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) { if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
return false; continue;
} }
if (excludeCommand && entry.command === excludeCommand) return false; if (excludeCommand && entry.command === excludeCommand) continue;
if (normalizedArgumentPrefix) { if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command)); const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) { if (!currentToken.startsWith(normalizedArgumentPrefix)) {
return false; continue;
} }
} }
if (hostId) { retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
} }
return results; return best.map(({ entry }) => entry);
} }
function getCurrentCommandToken(command: string): string { function getCurrentCommandToken(command: string): string {
@@ -535,28 +558,28 @@ function fuzzyScore(query: string, target: string): number {
if (query.length > target.length) return 0; if (query.length > target.length) return 0;
let score = 0; let score = 0;
let queryIdx = 0; let searchFrom = 0;
let prevMatchIdx = -2; let prevMatchIdx = -2;
for (let i = 0; i < target.length && queryIdx < query.length; i++) { for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
if (target[i] === query[queryIdx]) { // Skip unmatched runs in native string search, especially for long commands.
queryIdx++; const i = target.indexOf(query[queryIdx], searchFrom);
// First character bonus if (i < 0) return 0;
if (i === 0) score += 10; // First character bonus
// Consecutive match bonus if (i === 0) score += 10;
if (i === prevMatchIdx + 1) score += 5; // Consecutive match bonus
// Word boundary bonus if (i === prevMatchIdx + 1) score += 5;
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" || // Word boundary bonus
target[i - 1] === "-" || target[i - 1] === "_") { if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
score += 3; target[i - 1] === "-" || target[i - 1] === "_") {
} score += 3;
score += 1;
prevMatchIdx = i;
} }
score += 1;
prevMatchIdx = i;
searchFrom = i + 1;
} }
// All query characters must be matched return score;
return queryIdx === query.length ? score : 0;
} }
/** /**
@@ -47,3 +47,45 @@ test("renderer cursor acknowledges GPU publication separately from CPU sample an
historyId: "history", historyId: "history",
}); });
}); });
test("idle runtime polls do not notify subscribers until something changes", async () => {
const React = await import("react");
const { act, create } = await import("react-test-renderer");
const { useMonitorSnapshot } = await import("./monitorRuntimeStore");
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const idle = (takenAt: number): MonitorSnapshot => ({
historyMode: "replace",
runtimeId: "idle-poll-test",
epoch: 7,
takenAt,
running: [],
count: 0,
hostCount: 0,
activeSessions: [],
startedAt: {},
hosts: {},
});
let renders = 0;
function Probe() {
useMonitorSnapshot();
renders += 1;
return null;
}
// Later than anything earlier tests published, so this baseline wins.
const base = 1_000_000_000;
publishMonitorSnapshot(idle(base));
let renderer!: ReturnType<typeof create>;
act(() => {
renderer = create(React.createElement(Probe));
});
const baseline = renders;
act(() => {
for (let at = base + 1000; at <= base + 5000; at += 1000) publishMonitorSnapshot(idle(at));
});
assert.equal(renders, baseline, "unchanged idle replies must not re-render subscribers");
act(() => {
publishMonitorSnapshot({ ...idle(base + 6000), running: ["panel"], count: 1 });
});
assert.equal(renders, baseline + 1, "a real change still notifies");
act(() => renderer.unmount());
});
+23 -1
View File
@@ -14,8 +14,30 @@ const empty: MonitorSnapshot = {
}; };
let snapshot = empty; let snapshot = empty;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
export function isIdleMonitorSnapshot(value: MonitorSnapshot): boolean {
return value.running.length === 0
&& value.count === 0
&& value.hostCount === 0
&& value.activeSessions.length === 0
&& Object.keys(value.hosts).length === 0
&& Object.keys(value.startedAt).length === 0;
}
/** An idle runtime polled again: only `takenAt` moved, nothing to render. */
function isUnchangedIdleSnapshot(previous: MonitorSnapshot, next: MonitorSnapshot): boolean {
return isIdleMonitorSnapshot(previous)
&& isIdleMonitorSnapshot(next)
&& previous.epoch === next.epoch
&& previous.runtimeId === next.runtimeId
&& Boolean(previous.unavailable) === Boolean(next.unavailable);
}
export function publishMonitorSnapshot(next: MonitorSnapshot) { export function publishMonitorSnapshot(next: MonitorSnapshot) {
snapshot = mergeMonitorSnapshotHistory(snapshot, next); const merged = mergeMonitorSnapshotHistory(snapshot, next);
// The runtime is polled even when nothing is monitored; do not wake every
// subscriber (host-key dialog, monitor panels) for an unchanged idle reply.
if (merged === snapshot || isUnchangedIdleSnapshot(snapshot, merged)) return;
snapshot = merged;
for (const l of listeners) l(); for (const l of listeners) l();
} }
export function getMonitorHistoryCursor(): MonitorHistoryCursor { export function getMonitorHistoryCursor(): MonitorHistoryCursor {
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host, ManagedSource } from '../../domain/models';
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
function fixture() {
const resources = new PluginHostActionResources();
let active = true, authorized = 0, connections = 0, writes = 0;
let beforeRead: (() => void) | undefined;
let beforeCommit: (() => void) | undefined;
let duringAuthorize: (() => void) | undefined;
let storedSources: ManagedSource[] | undefined;
const vault: PluginHostActionVault = {
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
const nextSources = sources(storedSources ?? vault.managedSources);
beforeCommit?.();
await authorize?.();
writes++; vault.hosts = next;
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
},
};
resources.observe(vault.hosts);
const target = resources.metadata(synthetic.id);
const options = {
getVault: () => vault, resources,
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
};
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
storedSources(value: ManagedSource[]) { storedSources = value; },
};
}
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
});
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].password, synthetic.password);
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
assert.equal(f.vault.hosts[0].label, 'Reviewed');
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
});
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
const f = fixture(); f[point](() => f.cancel());
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
});
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
const f = fixture();
f.beforeRead(() => {
if (change === 'remove') f.vault.hosts = [];
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
if (change === 'scripts') f.vault.snippets = [];
if (change === 'locked') f.cancel();
});
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
});
test('create produces a saved SSH draft without executable or credential injection', async () => {
const f = fixture();
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
assert.equal(await executePluginHostAction(action, f.options), 'saved');
const created = f.vault.hosts[1];
assert.notEqual(created.id, synthetic.id);
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
});
const managedSources: ManagedSource[] = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
const f = fixture();
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
f.vault.readPersistedHosts = async () => [structuredClone(host)];
f.options.resources.observe(f.vault.hosts);
const target = f.options.resources.metadata(host.id);
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
});
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
const f = fixture(); f.vault.managedSources = managedSources;
const action: PluginHostAction = kind === 'create'
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
: { kind, target: f.target, draft: { group: 'B/Child' } };
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
const f = fixture(); f.vault.managedSources = managedSources;
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
assert.equal(f.vault.managedSources, managedSources);
});
test('a managed-source catalog replacement during preparation prevents the write', async () => {
const f = fixture();
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
const f = fixture(); f.storedSources(managedSources);
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
@@ -0,0 +1,102 @@
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
import type { PluginHostActionResources } from './pluginHostActionResources';
import type { AppVaultRuntime } from './appRuntimeBridge';
// Application action model. Public RPC types live only in plugin-contract.
export type PluginHostAction =
| { kind: 'create'; draft: PluginHostDraftFields }
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
| { kind: 'connect'; target: { resourceId: string; revision: string } };
export interface PluginHostActionVault {
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
isInitialized: boolean;
readPersistedHosts: () => Promise<Host[]>;
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
}
export interface PluginHostActionExecution {
getVault: () => PluginHostActionVault;
resources: PluginHostActionResources;
assertCurrent: () => void;
authorizeCommit: () => Promise<void>;
connect: (host: Host) => unknown;
}
const sameHost = (left: Host, right: Host) => (
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
);
/** Execute only a host-reviewed action. authorizeCommit claims separate,
* one-use main-process action authority after the final asynchronous work. */
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
const current = () => {
options.assertCurrent();
const vault = options.getVault();
if (!vault.isInitialized) throw new Error('Vault is unavailable');
options.resources.observe(vault.hosts);
return vault;
};
const initialVault = current();
const reviewedGroups = initialVault.groupConfigs;
const reviewedSnippets = initialVault.snippets;
const reviewedSources = initialVault.managedSources;
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
const assertSelected = () => {
const vault = current();
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
throw new Error('Selected host changed');
}
// A connection preview includes inherited and global automation. Any
// settings change while the preview is open requires fresh confirmation.
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
throw new Error('Saved connection settings changed');
}
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
return vault;
};
const persisted = await initialVault.readPersistedHosts();
assertSelected();
if (selected) {
const persistedHost = persisted.find((host) => host.id === selected.id);
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
}
if (action.kind === 'connect') {
await options.authorizeCommit();
assertSelected();
// The existing host connection handler owns authentication, saved scripts,
// and sessions. Its return value never reaches the sandboxed plugin.
options.connect(selected!);
return 'requested';
}
const nextHost = action.kind === 'create'
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
: applyPluginHostEdit(selected!, action.draft);
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
throw new Error('Managed SSH config changes require the Vault host editor');
}
const nextHosts = action.kind === 'create'
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
const keepSources = (sources: ManagedSource[]) => {
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
return sources;
};
const result = await initialVault.commitVaultImportTransaction(
nextHosts, updateGroups, keepSources,
undefined, persisted, undefined,
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
);
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
return 'saved';
}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host } from '../../domain/models';
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const metadata = resources.metadata(first.id);
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
resources.observe([first]);
assert.equal(resources.resolve(first.id, metadata.revision), first);
resources.observe([{ ...first }]);
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
const replaced = resources.metadata(first.id);
resources.observe([]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
resources.observe([first]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
});
test('group choices are explicit current members and never expand to later members', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const selection = [resources.metadata(first.id)];
resources.observe([first, { ...first, id: 'synthetic-2' }]);
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
assert.equal(resources.resolve(first.id, selection[0].revision), first);
});
@@ -0,0 +1,34 @@
import type { Host } from '../../domain/models';
/** Renderer-local revisions track replacement as well as visible edits. They
* are never authorization handles; only the main-process authority issues one. */
export class PluginHostActionResources {
private entries = new Map<string, { host: Host; revision: string }>();
observe(hosts: readonly Host[]) {
const next = new Map<string, { host: Host; revision: string }>();
for (const host of hosts) {
const existing = this.entries.get(host.id);
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
}
this.entries = next;
}
resolve(id: string, expectedRevision: string): Host {
const entry = this.entries.get(id);
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
return entry.host;
}
metadata(id: string) {
const entry = this.entries.get(id);
if (!entry) throw new Error('Selected host is unavailable');
const host = entry.host;
return {
resourceId: host.id, revision: entry.revision,
label: host.label, hostname: host.hostname, username: host.username,
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
group: host.group ?? '', tags: [...host.tags],
};
}
}
+5
View File
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
['tab', 'tab'], ['tab', 'tab'],
]); ]);
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'[data-plugin-editing]',
'input', 'input',
'textarea', 'textarea',
'select', 'select',
@@ -44,6 +45,10 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'.monaco-menu-container', '.monaco-menu-container',
].join(', '); ].join(', ');
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
}
function hasEditableShortcutAncestor(node: unknown): boolean { function hasEditableShortcutAncestor(node: unknown): boolean {
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest; const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
return typeof closest === 'function' return typeof closest === 'function'
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('React 19 changes are staged as safe data before any host rendering', async () => {
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const staged = createPluginRemoteTree();
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
const observer = new RemoteMutationObserver({
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
call() { throw new Error('Host method calls are unavailable'); },
});
const container = document.getElementById('remote')!;
observer.observe(container);
const root = createRoot(container);
function Example() {
const [count, setCount] = useState(0);
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
}
try {
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
const first = snapshots.at(-1)!;
const section = first.children[0]; assert.equal(section.type, 1);
if (section.type !== 1) throw new Error('Expected section');
assert.equal(section.attributes.class, 'card');
assert.match(section.attributes.style, /display:\s*grid/);
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
const updated = JSON.stringify(snapshots.at(-1));
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
assert.equal(staged.isFailed(), false);
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
assert.deepEqual(staged.snapshot(), { children: [] });
assert.equal(staged.isFailed(), true);
assert.throws(() => staged.apply([]));
} finally {
observer.disconnect(); await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
});
test('inserting a child into an existing void element fails the view', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
assert.equal(tree.isFailed(), true);
assert.deepEqual(tree.snapshot(), { children: [] });
});
+41
View File
@@ -0,0 +1,41 @@
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
type WireNode = { id: string; type: 3 | 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
};
type WireTree = { children: WireNode[] };
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
export function createPluginRemoteTree() {
const receiver = new RemoteReceiver();
let current: WireTree = { children: [] };
let failed = false;
const project = (node: unknown): WireNode => {
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
};
return {
apply(input: unknown): WireTree {
if (failed) throw new Error('Plugin view has been closed after an invalid update');
try {
validatePluginMutationBatch(input);
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
// defaults those to properties, so explicitly mark them as attributes.
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
const next = { children: receiver.root.children.map(project) };
validatePluginRemoteTree(next);
current = next;
return next;
} catch (error) {
failed = true;
current = { children: [] };
throw error;
}
},
snapshot(): WireTree { return current; },
isFailed(): boolean { return failed; },
};
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPluginTerminalSelectionSource } from './usePluginTerminalSelection';
import { readPluginTerminalSelection, registerTerminalSelectionSource } from '../../infrastructure/plugins/terminalSelectionRegistry';
import type { Terminal } from '@xterm/xterm';
function fixture() {
let reads = 0;
const term = { cols: 80, element: { isConnected: true },
getSelectionPosition: () => ({ start: { x: 0, y: 0 }, end: { x: 3, y: 0 } }),
getSelection() { reads++; return 'raw'; },
buffer: { active: { getLine: () => ({ length: 3, translateToString: () => 'normalized' }) } },
} as unknown as Terminal;
const state = { term, sessionId: 'a', bootEpoch: 1, visible: true, isFocusedPane: true, connected: true, sensitive: false, normalize: false, label: 'Synthetic A' };
const source = createPluginTerminalSelectionSource(() => state, 'renderer');
return { term, state, source, get reads() { return reads; } };
}
test('registered reader honors explicit false normalization and reads only on Preview', () => {
const f = fixture();
const cleanup = registerTerminalSelectionSource('a', f.source);
try {
assert.equal(f.reads, 0);
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'raw');
assert.equal(f.reads, 1);
f.state.normalize = true;
assert.equal(readPluginTerminalSelection('a', 'renderer:1'), 'normalized');
} finally { cleanup(); }
});
test('separate terminal references, hidden/attached renderer, reconnect and empty selection have no fallback', () => {
const a = fixture(), b = fixture(); b.state.sessionId = 'b';
const cleanupA = registerTerminalSelectionSource('a', a.source), cleanupB = registerTerminalSelectionSource('b', b.source);
try {
assert.throws(() => readPluginTerminalSelection('wrong', 'renderer:1'), /no longer/);
a.term.getSelection = () => '';
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /between 1/);
assert.equal(b.reads, 0);
a.state.bootEpoch++;
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /no longer/);
for (const field of ['visible', 'isFocusedPane', 'connected'] as const) {
a.state[field] = false;
assert.equal(a.source.target(), null);
a.state[field] = true;
}
a.state.sensitive = true;
assert.equal(a.source.target(), null);
a.state.sensitive = false;
Object.defineProperty(a.term.element, 'isConnected', { value: false });
assert.equal(a.source.target(), null);
} finally { cleanupA(); cleanupB(); }
assert.throws(() => readPluginTerminalSelection('b', 'renderer:1'), /no longer/);
});
test('oversized ranges refuse to materialize selection and UTF-8 bytes are bounded', () => {
const f = fixture(), cleanup = registerTerminalSelectionSource('a', f.source);
try {
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 100 } });
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /bounded/);
assert.equal(f.reads, 0);
f.term.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 1, y: 0 } });
f.term.getSelection = () => '\u20ac'.repeat(1366);
assert.throws(() => readPluginTerminalSelection('a', 'renderer:1'), /4096 bytes/);
} finally { cleanup(); }
});
test('real pane snapshots give sharing ownership to solo, Focus mode, and only the selected split pane', async () => {
const { getTerminalPaneRenderSnapshot, parseTerminalPaneRenderSnapshot, resolveTerminalPaneInputProps } = await import('../../components/terminalPaneVisibility');
const workspaceById = new Map<string, import('../../types').Workspace>([
['split', { id: 'split', title: 'Split', viewMode: 'split', focusedSessionId: 'split-a', root: { id: 'split-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'split-a' }, { id: 'b', type: 'pane', sessionId: 'split-b' }] } }],
['focus', { id: 'focus', title: 'Focus', viewMode: 'focus', focusedSessionId: 'focus-a', root: { id: 'focus-root', type: 'split', direction: 'vertical', children: [{ id: 'a', type: 'pane', sessionId: 'focus-a' }, { id: 'b', type: 'pane', sessionId: 'focus-b' }] } }],
]);
const cases = [
{ name: 'solo', activeTabId: 'solo', sessionId: 'solo', eligible: true },
{ name: 'inactive solo', activeTabId: 'solo', sessionId: 'other', eligible: false },
{ name: 'Focus mode', activeTabId: 'focus', sessionId: 'focus-a', sessionWorkspaceId: 'focus', eligible: true },
{ name: 'other Focus pane', activeTabId: 'focus', sessionId: 'focus-b', sessionWorkspaceId: 'focus', eligible: false },
{ name: 'selected split', activeTabId: 'split', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: true },
{ name: 'other visible split', activeTabId: 'split', sessionId: 'split-b', sessionWorkspaceId: 'split', eligible: false },
{ name: 'inactive workspace', activeTabId: 'solo', sessionId: 'split-a', sessionWorkspaceId: 'split', eligible: false },
{ name: 'hidden layer', activeTabId: 'solo', sessionId: 'solo', isTerminalLayerVisible: false, eligible: false },
];
for (const item of cases) {
const snapshot = getTerminalPaneRenderSnapshot({ ...item, workspaceById, isTerminalLayerVisible: item.isTerminalLayerVisible ?? true });
const inputProps = resolveTerminalPaneInputProps(parseTerminalPaneRenderSnapshot(snapshot));
const f = fixture();
const state = { ...f.state, sessionId: item.sessionId, visible: inputProps.isVisible,
isFocusedPane: inputProps.isFocusedPane };
const source = createPluginTerminalSelectionSource(() => state, 'derived-pane');
assert.equal(source.target() !== null, item.eligible, `${item.name}: ${snapshot}`);
if (item.name === 'solo' || item.name === 'Focus mode') assert.equal(inputProps.isFocused, false, 'do not redefine the legacy focus bit');
}
});
+2 -133
View File
@@ -1,111 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
import { import { PluginViewTabStore } from './pluginViewTabStore';
PluginViewLifecycleController,
consumeClosedPluginViewInstance,
reconcilePluginViewTabCatalog,
markPluginViewOpenTokensClosed,
reconcileClosedPluginView,
rememberClosedPluginViewInstance,
resolvePluginViewSnapshotSelection,
shouldReconcilePluginViewTabCatalog,
withdrawPluginViewTab,
type HostedPluginViewState,
} from './pluginViewLifecycle.ts';
import { PluginViewTabStore } from './pluginViewTabStore.ts';
function view(id: string, tabId?: string): HostedPluginViewState {
return {
id,
viewId: `view.${id}`,
scopeId: 'window:main',
retainContextWhenHidden: false,
...(tabId ? { tabId } : {}),
};
}
test('host close events clear the active renderer instance and identify its native tab', () => {
const current = view('active', 'plugin-view:publisher.plugin:view.active');
const result = reconcileClosedPluginView({
current,
retained: new Map([['retained', view('retained')]]),
instanceId: 'active',
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.closedTabId, current.tabId);
assert.equal(result.retained.size, 1);
});
test('host close events remove retained instances without dismissing another active view', () => {
const current = view('active');
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
const result = reconcileClosedPluginView({
current,
retained: new Map([
['closed', retainedTab],
['kept', view('retained-kept')],
]),
instanceId: 'retained-closed',
});
assert.equal(result.current, current);
assert.equal(result.matchedCurrent, false);
assert.equal(result.matchedRetained, true);
assert.deepEqual([...result.retained.keys()], ['kept']);
assert.equal(result.closedTabId, retainedTab.tabId);
});
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
const tabId = 'plugin-view:publisher.plugin:view.shared';
const result = withdrawPluginViewTab({
current: view('active', tabId),
retained: new Map([
['same-tab', view('retained-same', tabId)],
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
]),
tabId,
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.matchedRetained, true);
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
assert.deepEqual([...result.retained.keys()], ['other-tab']);
});
test('an early host close tombstone is consumed when the open response arrives later', () => {
const tombstones = new Set<string>();
rememberClosedPluginViewInstance(tombstones, 'instance-early');
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
for (let index = 0; index < 300; index += 1) {
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
}
assert.equal(tombstones.size, 256);
assert.equal(tombstones.has('instance-0'), false);
});
test('explicit close marks only in-flight opens owned by the closed surface', () => {
const first = Symbol('first');
const second = Symbol('second');
const explicitlyClosed = new Set<symbol>();
const opening = new Map<string, Set<symbol>>([
['window:main\0view.first', new Set([first])],
['window:main\0view.second', new Set([second])],
]);
assert.equal(markPluginViewOpenTokensClosed(
opening,
explicitlyClosed,
'window:main\0view.first',
), 1);
assert.deepEqual([...explicitlyClosed], [first]);
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
});
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => { test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
const previous = { const previous = {
@@ -184,30 +80,3 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
assert.equal(store.getTabs()[0]?.title, 'Localized View'); assert.equal(store.getTabs()[0]?.title, 'Localized View');
assert.equal(activeTabId, tab.id); assert.equal(activeTabId, tab.id);
}); });
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
const tabId = 'plugin-view:publisher.plugin:view.shared';
const active = view('active', tabId);
const retained = view('retained');
controller.setCurrent(active);
controller.retain('window:main\0view.retained', retained);
const token = controller.beginOpen({
viewKey: 'window:main\0view.active',
tabId,
});
const closed = controller.handleTabClose(tabId);
assert.deepEqual(closed.instanceIds, [active.id]);
assert.equal(controller.shouldCloseOpen(token), true);
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
assert.equal(controller.shouldCloseOpen(token), false);
const early = controller.handleHostClose('instance-before-open-response');
assert.equal(early.matchedCurrent, false);
assert.equal(early.matchedRetained, false);
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
assert.equal(controller.getCurrent(), null);
});
-246
View File
@@ -1,11 +1,3 @@
export interface HostedPluginViewState {
id: string;
viewId: string;
scopeId: string;
retainContextWhenHidden: boolean;
tabId?: string;
}
export interface PluginViewSnapshotSelection<T> { export interface PluginViewSnapshotSelection<T> {
requestViewId: string; requestViewId: string;
contextKey: string; contextKey: string;
@@ -81,241 +73,3 @@ export function reconcilePluginViewTabCatalog({
store.refreshMetadata(entries); store.refreshMetadata(entries);
return true; return true;
} }
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
current,
retained,
instanceId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
instanceId: string;
}): {
current: T | null;
retained: Map<string, T>;
matchedCurrent: boolean;
matchedRetained: boolean;
closedTabId?: string;
} {
const matchedCurrent = current?.id === instanceId;
let matchedRetained = false;
let retainedTabId: string | undefined;
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.id === instanceId) {
matchedRetained = true;
retainedTabId = view.tabId;
}
else nextRetained.set(key, view);
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
matchedCurrent,
matchedRetained,
...((matchedCurrent ? current?.tabId : retainedTabId)
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
: {}),
};
}
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
current,
retained,
tabId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
tabId: string;
}): {
current: T | null;
retained: Map<string, T>;
instanceIds: string[];
matchedCurrent: boolean;
matchedRetained: boolean;
} {
const matchedCurrent = current?.tabId === tabId;
let matchedRetained = false;
const instanceIds: string[] = [];
if (matchedCurrent && current) instanceIds.push(current.id);
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.tabId === tabId) {
matchedRetained = true;
instanceIds.push(view.id);
} else {
nextRetained.set(key, view);
}
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
instanceIds,
matchedCurrent,
matchedRetained,
};
}
export function rememberClosedPluginViewInstance(
tombstones: Set<string>,
instanceId: string,
limit = 256,
): void {
if (tombstones.size >= limit) {
const oldest = tombstones.values().next().value;
if (typeof oldest === 'string') tombstones.delete(oldest);
}
tombstones.add(instanceId);
}
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
return tombstones.delete(instanceId);
}
export function markPluginViewOpenTokensClosed(
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
explicitlyClosedTokens: Set<symbol>,
ownerKey: string | null | undefined,
): number {
if (!ownerKey) return 0;
const tokens = openingTokens.get(ownerKey);
if (!tokens) return 0;
for (const token of tokens) explicitlyClosedTokens.add(token);
return tokens.size;
}
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
private current: T | null = null;
private retained = new Map<string, T>();
private readonly closedInstanceIds = new Set<string>();
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
private readonly openingTokensByView = new Map<string, Set<symbol>>();
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
getCurrent(): T | null {
return this.current;
}
setCurrent(view: T | null): void {
this.current = view;
}
takeCurrent(): T | null {
const current = this.current;
this.current = null;
return current;
}
retain(key: string, view: T): void {
this.retained.set(key, view);
}
takeRetained(key: string): T | null {
const retained = this.retained.get(key) ?? null;
if (retained) this.retained.delete(key);
return retained;
}
removeRetained(key: string): void {
this.retained.delete(key);
}
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
const removed: T[] = [];
for (const [key, view] of this.retained) {
if (!predicate(view)) continue;
this.retained.delete(key);
removed.push(view);
}
return removed;
}
handleHostClose(instanceId: string) {
const next = reconcileClosedPluginView({
current: this.current,
retained: this.retained,
instanceId,
});
this.current = next.current;
this.retained = next.retained;
if (!next.matchedCurrent && !next.matchedRetained) {
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
return next;
}
handleTabClose(tabId: string) {
const next = withdrawPluginViewTab({
current: this.current,
retained: this.retained,
tabId,
});
this.current = next.current;
this.retained = next.retained;
markPluginViewOpenTokensClosed(
this.openingTokensByTab,
this.explicitlyClosedOpenTokens,
tabId,
);
return next;
}
markViewClosed(viewKey: string | null | undefined): number {
return markPluginViewOpenTokensClosed(
this.openingTokensByView,
this.explicitlyClosedOpenTokens,
viewKey,
);
}
beginOpen({
viewKey,
tabId,
label,
}: {
viewKey: string;
tabId?: string;
label?: string;
}): symbol {
const token = Symbol(label ?? tabId ?? viewKey);
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
viewTokens.add(token);
this.openingTokensByView.set(viewKey, viewTokens);
if (tabId) {
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
tabTokens.add(token);
this.openingTokensByTab.set(tabId, tabTokens);
}
return token;
}
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
this.explicitlyClosedOpenTokens.delete(token);
const viewTokens = this.openingTokensByView.get(viewKey);
viewTokens?.delete(token);
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
if (!tabId) return;
const tabTokens = this.openingTokensByTab.get(tabId);
tabTokens?.delete(token);
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
}
shouldCloseOpen(token: symbol): boolean {
return this.explicitlyClosedOpenTokens.has(token);
}
consumeHostClose(instanceId: string): boolean {
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
drain(): T[] {
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
this.current = null;
this.retained.clear();
this.closedInstanceIds.clear();
this.openingTokensByTab.clear();
this.openingTokensByView.clear();
this.explicitlyClosedOpenTokens.clear();
return values;
}
}
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
import { PluginViewSessions } from './pluginViewSession';
function fixture() {
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
}
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
test('a hidden view resumes its existing state and plugin instance', async () => {
const f = fixture(); const store = new PluginViewSessions();
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
first.release(); const next = store.acquire('view', f.bridge, request);
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
});
test('host invalidation clears retained content and a reopen creates a new instance', () => {
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
});
test('unacknowledged input is not overwritten by an older plugin update', () => {
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
});
test('idle instances are evicted while active instances are never silently replaced', () => {
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
});
+78
View File
@@ -0,0 +1,78 @@
import { createPluginRemoteTree } from './pluginRemoteTree';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
/** Owns a window's bounded, retained UI instances independently of React mounts. */
export class PluginViewSessions {
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
const id = crypto.randomUUID();
const tree = createPluginRemoteTree();
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
const cleanup: Array<() => void> = [];
const dispose = (failed = false) => {
if (disposed) return;
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
this.sessions.delete(key);
publish({ ...empty(), loading: false, error: failed });
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
};
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
try {
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
} catch { dispose(true); }
}));
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
}).catch(() => dispose(true));
return {
viewId: request.viewId,
scope: request.scopeId,
image: async (path: string) => {
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
const result = await bridge.getPluginViewResource({ instanceId: id, path });
if (disposed) throw new Error('Plugin view closed');
return result.url;
},
get idle() { return users === 0; },
getSnapshot: () => state,
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
acquire: () => { clearTimeout(timer); users++; },
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
send: (event: HostDrawnViewEvent) => {
if (disposed || users === 0) return;
publish({ ...state, settled: false });
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
},
dispose,
};
}
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
let session = this.sessions.get(key);
if (!session) {
while (this.sessions.size >= this.capacity) {
const candidate = [...this.sessions.values()].find((item) => item.idle);
if (!candidate) throw new Error('Too many open plugin views');
candidate.dispose();
}
session = this.create(key, bridge, request); this.sessions.set(key, session);
}
session.acquire(); return session;
}
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
}
export const pluginViewSessions = new PluginViewSessions();
+7
View File
@@ -49,6 +49,7 @@ import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY, STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT, STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY, STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
@@ -99,6 +100,7 @@ interface UseSettingsIpcSyncParams {
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>; setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>; setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void; setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>; setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>; setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>; setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
@@ -148,6 +150,7 @@ export function useSettingsIpcSync({
setIsHotkeyRecordingState, setIsHotkeyRecordingState,
setGlobalHotkeyEnabled, setGlobalHotkeyEnabled,
setWindowOpacity, setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant, setAppIconVariant,
setAutoUpdateEnabled, setAutoUpdateEnabled,
setAutoLaunchEnabled, setAutoLaunchEnabled,
@@ -279,6 +282,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WINDOW_OPACITY) { if (key === STORAGE_KEY_WINDOW_OPACITY) {
setWindowOpacity(value); setWindowOpacity(value);
} }
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') {
setWindowBackgroundBlur((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_APP_ICON_VARIANT) { if (key === STORAGE_KEY_APP_ICON_VARIANT) {
const nextVariant = resolveAppIconVariant(value); const nextVariant = resolveAppIconVariant(value);
setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant)); setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant));
@@ -371,6 +377,7 @@ export function useSettingsIpcSync({
setFollowAppTerminalThemeState, setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled, setGlobalHotkeyEnabled,
setWindowOpacity, setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant, setAppIconVariant,
setHotkeyScheme, setHotkeyScheme,
setIsHotkeyRecordingState, setIsHotkeyRecordingState,
+14 -4
View File
@@ -53,6 +53,7 @@ import {
STORAGE_KEY_UI_LANGUAGE, STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE, STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY, STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT, STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys'; } from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant'; import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
@@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams {
globalHotkeyEnabled: boolean; globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean; autoUpdateEnabled: boolean;
windowOpacity: number; windowOpacity: number;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant; appIconVariant: AppIconVariant;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>; setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>; setLightUiThemeId: Dispatch<SetStateAction<string>>;
@@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams {
setExplorerContextMenuEnabledState: (enabled: boolean) => void; setExplorerContextMenuEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>; setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: (raw: unknown) => void; setWindowOpacity: (raw: unknown) => void;
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>; setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>; setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>; setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
@@ -184,7 +187,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding, showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant, globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent, setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage, setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId, setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode, setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState, setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState, setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState, setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState, setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) { }: UseSettingsStorageSyncParams) {
@@ -208,7 +211,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding, showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant, globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
}); });
settingsSnapshotRef.current = { settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion, theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
@@ -218,7 +221,7 @@ export function useSettingsStorageSync({
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding, showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant, globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant,
}; };
// Listen for storage changes from other windows (cross-window sync) // Listen for storage changes from other windows (cross-window sync)
@@ -523,6 +526,12 @@ export function useSettingsStorageSync({
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) { if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
setWindowOpacity(e.newValue); setWindowOpacity(e.newValue);
} }
if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.windowBackgroundBlur) {
setWindowBackgroundBlur(newValue);
}
}
if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) { if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) {
const newValue = resolveAppIconVariant(e.newValue); const newValue = resolveAppIconVariant(e.newValue);
if (newValue !== s.appIconVariant) { if (newValue !== s.appIconVariant) {
@@ -566,6 +575,7 @@ export function useSettingsStorageSync({
setGlobalHotkeyEnabled, setGlobalHotkeyEnabled,
setHostClickBehaviorState, setHostClickBehaviorState,
setWindowOpacity, setWindowOpacity,
setWindowBackgroundBlur,
setAppIconVariant, setAppIconVariant,
setHotkeyScheme, setHotkeyScheme,
setLightUiThemeId, setLightUiThemeId,
@@ -393,12 +393,10 @@ export function useSftpFollowTerminalCwd({
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath }; handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
} }
}, [ }, [
activeSessionId,
activeTerminalCwd, activeTerminalCwd,
activeTerminalCwdTrusted, activeTerminalCwdTrusted,
canFollowTerminalCwd, canFollowTerminalCwd,
effectiveFollowTerminalCwd, effectiveFollowTerminalCwd,
focusedSessionId,
hasActiveWork, hasActiveWork,
isVisible, isVisible,
onGetTerminalCwd, onGetTerminalCwd,
@@ -7,6 +7,7 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY, STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_WINDOW_BACKGROUND_BLUR,
STORAGE_KEY_APP_ICON_VARIANT, STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY, STORAGE_KEY_HTTP_NETWORK_PROXY,
} from '../../infrastructure/config/storageKeys'; } from '../../infrastructure/config/storageKeys';
@@ -17,6 +18,8 @@ import {
} from '../../domain/httpNetworkProxy'; } from '../../domain/httpNetworkProxy';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isMacPlatform } from '../../lib/utils';
import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur';
import { import {
parseWindowOpacityRecord, parseWindowOpacityRecord,
serializeWindowOpacityRecord, serializeWindowOpacityRecord,
@@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams {
autoLaunchEnabled: boolean; autoLaunchEnabled: boolean;
windowOpacityRecord: WindowOpacityRecord; windowOpacityRecord: WindowOpacityRecord;
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>; windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
windowBackgroundBlur: boolean;
appIconVariant: AppIconVariant; appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean; autoUpdateEnabled: boolean;
httpNetworkProxy: HttpNetworkProxySettings; httpNetworkProxy: HttpNetworkProxySettings;
@@ -68,6 +72,7 @@ export function useSystemSettingsEffects({
autoLaunchEnabled, autoLaunchEnabled,
windowOpacityRecord, windowOpacityRecord,
windowOpacityMutationSourceRef, windowOpacityMutationSourceRef,
windowBackgroundBlur,
appIconVariant, appIconVariant,
autoUpdateEnabled, autoUpdateEnabled,
httpNetworkProxy, httpNetworkProxy,
@@ -300,6 +305,49 @@ export function useSystemSettingsEffects({
persistMountedRef, persistMountedRef,
]); ]);
// Window chrome also applies in detached sessions, independently of the
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
// Only the windows that own settings persistence drive the shared native
// state; detached/editor windows consume it (see applySharedWindowSetting).
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur);
}, [
enabled,
windowBackgroundBlur,
notifySettingsChanged,
persistMountedRef,
]);
const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive(
windowBackgroundBlur,
windowOpacityRecord.opacity,
);
// Applies in every app-content window, including detached sessions: the
// transparent backing only looks right when the renderer class matches the
// native vibrancy the main process put on that window.
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive);
document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity));
return () => {
document.documentElement.classList.remove('window-background-blur');
document.documentElement.style.removeProperty('--window-content-opacity');
};
}, [windowBackgroundBlurActive, windowOpacityRecord.opacity]);
// setWindowBackgroundBlur is a main-process handler that retargets every
// app-content window, so a single owner must drive it.
useEffect(() => {
if (!enabled) return;
netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => {
console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err);
});
}, [enabled, windowBackgroundBlur]);
// Persist and sync app icon variant // Persist and sync app icon variant
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
@@ -0,0 +1,139 @@
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 { JSDOM } from 'jsdom';
import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys';
import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy';
import { useSystemSettingsEffects } from './systemSettingsEffects';
type Harness = {
dom: JSDOM;
params: Parameters<typeof useSystemSettingsEffects>[0];
bridgeRequests: boolean[];
blurNotifications: unknown[];
restore: () => void;
};
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const replacements = {
window: dom.window,
document: dom.window.document,
navigator: dom.window.navigator,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const [key, value] of Object.entries(replacements)) {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
const bridgeRequests: boolean[] = [];
const blurNotifications: unknown[] = [];
Object.assign(dom.window, {
netcatty: {
setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); },
},
});
const noop = () => {};
const params: Parameters<typeof useSystemSettingsEffects>[0] = {
enabled,
toggleWindowHotkey: '',
globalHotkeyEnabled: false,
closeToTray: false,
showTrayIcon: false,
autoLaunchEnabled: false,
windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 },
windowOpacityMutationSourceRef: { current: 'local' },
windowBackgroundBlur,
appIconVariant: 'original',
autoUpdateEnabled: false,
httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY,
persistMountedRef: { current: true },
setHotkeyRegistrationError: noop,
setAutoUpdateEnabled: noop,
setAppIconVariant: noop,
setAutoLaunchEnabled: noop,
setAutoLaunchSupported: noop,
notifySettingsChanged: (key, value) => {
if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value);
},
};
return {
dom,
params,
bridgeRequests,
blurNotifications,
restore: () => {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
},
};
}
const Probe = (props: Parameters<typeof useSystemSettingsEffects>[0]) => {
useSystemSettingsEffects(props);
return null;
};
test('opacity changes and remounts preserve the chosen blur preference', async () => {
const harness = setUp(true, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } };
await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
assert.deepEqual(bridgeRequests, [true]);
assert.deepEqual(blurNotifications, [true]);
await act(async () => { renderer!.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true');
await act(async () => { renderer!.update(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false');
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.deepEqual(bridgeRequests, [true, true, false]);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test('detached session windows only apply the blur class, never own the setting', async () => {
// Peer session windows run with enableSystemEffects off. They must still
// paint the transparent backing, but persistence, cross-window broadcast and
// the (all-windows) native vibrancy IPC stay with the settings owners.
const harness = setUp(false, true);
const { dom, params, bridgeRequests, blurNotifications, restore } = harness;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(React.createElement(Probe, params)); });
assert.equal(document.documentElement.classList.contains('window-background-blur'), true);
assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
await act(async () => {
renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false }));
});
assert.equal(document.documentElement.classList.contains('window-background-blur'), false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null);
assert.deepEqual(bridgeRequests, []);
assert.deepEqual(blurNotifications, []);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
@@ -30,3 +30,20 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
unsubscribe(); unsubscribe();
assert.ok(versions.length >= 2); assert.ok(versions.length >= 2);
}); });
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
const a = 'revision-a'; const b = 'revision-b';
terminalCwdStore.setCwd(a, '/same', 'osc7');
const revisionA = terminalCwdStore.getSessionRevision(a);
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
const globalVersion = terminalCwdStore.getVersion();
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
assert.equal(terminalCwdStore.getVersion(), globalVersion);
const revisionB = terminalCwdStore.getSessionRevision(b);
terminalCwdStore.prune(new Set([b]));
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
terminalCwdStore.prune(new Set());
});
+14 -1
View File
@@ -10,11 +10,16 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
class TerminalCwdStore { class TerminalCwdStore {
private cwdBySession = new Map<string, string>(); private cwdBySession = new Map<string, string>();
private sourceBySession = new Map<string, TerminalCwdSource>(); private sourceBySession = new Map<string, TerminalCwdSource>();
private revisionBySession = new Map<string, number>();
private revision = 0;
private version = 0; private version = 0;
private listeners = new Set<Listener>(); private listeners = new Set<Listener>();
getVersion = (): number => this.version; getVersion = (): number => this.version;
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
getCwd = (sessionId: string | null | undefined): string | null => { getCwd = (sessionId: string | null | undefined): string | null => {
if (!sessionId) return null; if (!sessionId) return null;
return this.cwdBySession.get(sessionId) ?? null; return this.cwdBySession.get(sessionId) ?? null;
@@ -39,7 +44,10 @@ class TerminalCwdStore {
const nextSource = next const nextSource = next
? (source ?? (current === next ? currentSource : undefined) ?? "unknown") ? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
: undefined; : undefined;
if (current === next && currentSource === nextSource) return false; if (current === next && currentSource === nextSource) {
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
return false;
}
if (next) { if (next) {
this.cwdBySession.set(sessionId, next); this.cwdBySession.set(sessionId, next);
@@ -48,6 +56,7 @@ class TerminalCwdStore {
this.cwdBySession.delete(sessionId); this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId); this.sourceBySession.delete(sessionId);
} }
this.revisionBySession.set(sessionId, ++this.revision);
this.version += 1; this.version += 1;
for (const listener of this.listeners) { for (const listener of this.listeners) {
listener(); listener();
@@ -61,9 +70,13 @@ class TerminalCwdStore {
if (!validSessionIds.has(sessionId)) { if (!validSessionIds.has(sessionId)) {
this.cwdBySession.delete(sessionId); this.cwdBySession.delete(sessionId);
this.sourceBySession.delete(sessionId); this.sourceBySession.delete(sessionId);
this.revisionBySession.delete(sessionId);
changed = true; changed = true;
} }
} }
for (const id of this.revisionBySession.keys()) {
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
}
if (!changed) return; if (!changed) return;
this.version += 1; this.version += 1;
for (const listener of this.listeners) { for (const listener of this.listeners) {
@@ -0,0 +1,230 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
let cwd = '/old'; let ready = true; let current = true;
const writes: string[] = [];
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'test-directory', confirmationMs: 10,
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
isCurrent: () => current, isReady: () => ready,
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
});
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
}
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
const ctx = setup(); const accepted: string[] = [];
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
assert.deepEqual(accepted, ['accepted']);
assert.deepEqual(ctx.published, ['/next']);
ctx.controller.dispose();
});
test('busy, control-character and stale sessions never receive bytes', async () => {
const ctx = setup(); ctx.busy();
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
const invalid = setup();
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
const stale = setup(); stale.stale();
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
});
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
let finish!: (path: string) => void;
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
ctx.busy(); finish('/next');
await assert.rejects(first, { code: 'busy' });
assert.deepEqual(ctx.writes, []);
});
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
let finish!: (cwd: string) => void;
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
const read = ctx.controller.refresh();
ctx.controller.dispose(); finish('/stale');
await assert.rejects(read, { code: 'unavailable' });
assert.deepEqual(ctx.published, []);
});
test('a failed cd keeps the observed cwd and never retries the write', async () => {
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
assert.ok(ctx.published.every(value => value === '/old'));
});
test('directory symlinks confirm against physical cwd', async () => {
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
await ctx.controller.change('/link', new AbortController().signal, () => {});
assert.equal(ctx.published.at(-1), '/real');
});
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
let finish!: (cwd: string) => void;
let reads = 0;
const ctx = setup({ cwd: () => {
reads++;
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
} });
const oldRead = ctx.controller.refresh();
const queued = ctx.controller.refresh();
finish('/before-cd');
await Promise.all([oldRead, queued]);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 2);
assert.equal(ctx.published.at(-1), '/after-cd');
ctx.controller.dispose();
});
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
const id = 'strict-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
finish('/actual-after-cd');
assert.equal(await pending, '/actual-after-cd');
assert.deepEqual(published, ['/actual-after-cd']);
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
});
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
const id = 'pre-write-osc7-after-other-session';
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
t.after(() => controller.dispose());
const changing = controller.change('/after', new AbortController().signal, () => {});
await new Promise(resolve => setImmediate(resolve));
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
finish('/after');
await changing;
assert.deepEqual(published, ['/after']);
assert.equal(terminalCwdStore.getCwd(id), '/after');
});
for (const nextCwd of ['/new-osc7', '/original']) {
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
const id = 'same-session-osc7-' + nextCwd;
let finish!: (cwd: string) => void;
const published: string[] = [];
terminalCwdStore.setCwd(id, '/original', 'stale');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
t.after(() => controller.dispose());
const pending = controller.refresh();
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
finish('/older-backend-result');
assert.equal(await pending, nextCwd);
assert.deepEqual(published, []);
});
}
for (const probe of ['fails', 'stalls'] as const) {
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
const id = 'osc7-confirm-' + probe;
const writes: string[] = [];
terminalCwdStore.setCwd(id, '/old', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
io: {
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write: data => {
writes.push(data);
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
},
},
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
});
t.after(() => controller.dispose());
await controller.change('/next', new AbortController().signal, () => {});
assert.deepEqual(writes, ["cd -- '/next'\r"]);
assert.equal(controller.hasConfirmedCwd(), true);
});
}
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
const id = 'probe-after-pre-write-osc7';
terminalCwdStore.setCwd(id, '/before', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
});
t.after(() => controller.dispose());
await controller.change('/after', new AbortController().signal, () => {});
assert.equal(terminalCwdStore.getCwd(id), '/after');
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
});
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
const id = 'osc7-revision-confirm-' + report;
terminalCwdStore.setCwd(id, '/next', 'osc7');
const controller = createTerminalDirectoryController({
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
io: {
cwd: async () => { throw new Error('no process cwd support'); },
list: async () => ({ entries: [], home: null }), realpath: async value => value,
write() {
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
));
},
},
publish() {},
});
t.after(() => controller.dispose());
const change = controller.change('/next', new AbortController().signal, () => {});
if (report === 'fresh-unchanged') await change;
else await assert.rejects(change, { code: 'changeFailed' });
});
}
for (const cancellation of ['abort', 'dispose'] as const) {
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
const id = 'osc7-cancel-' + cancellation;
const abort = new AbortController();
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
realpath: async value => value,
write() { setImmediate(() => {
if (cancellation === 'abort') abort.abort(); else controller.dispose();
terminalCwdStore.setCwd(id, '/next', 'osc7');
}); } },
publish() {},
});
t.after(() => controller.dispose());
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
});
}
@@ -0,0 +1,162 @@
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
import { terminalCwdStore } from './terminalCwdStore';
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
/** One registration represents one connected interactive shell generation. */
export function createTerminalDirectoryController(options: {
sessionId: string;
io: TerminalDirectoryIO;
isCurrent: () => boolean;
isReady: () => boolean;
publish: (cwd: string) => void;
confirmationMs?: number;
}) {
let disposed = false;
let changing = false;
let confirmed = false;
let refreshing: Promise<string> | null = null;
let refreshAgain = false;
const lifetime = new AbortController();
const current = () => !disposed && options.isCurrent();
function check(signal?: AbortSignal) {
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
}
function ready() {
check();
if (!options.isReady()) throw new DirectoryPickerError('busy');
}
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const finish = (error?: unknown, value?: T) => {
clearTimeout(timer);
lifetime.signal.removeEventListener('abort', cancel);
signal?.removeEventListener('abort', cancel);
if (error) reject(error); else resolve(value!);
};
const cancel = () => finish(new DirectoryPickerError('unavailable'));
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
lifetime.signal.addEventListener('abort', cancel, { once: true });
signal?.addEventListener('abort', cancel, { once: true });
if (signal?.aborted || lifetime.signal.aborted) cancel();
task.then(value => finish(undefined, value), error => finish(error));
});
}
async function refresh(submittedRevision?: number): Promise<string> {
check();
const activeShellCwd = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
? cwd : null;
};
// A process probe may read the outer PTY shell while a nested shell is
// active. OSC 7 remains authoritative across debounce delays, completion
// events and picker remounts. Only a cd submission may probe past its own
// pre-write report while waiting for fresh confirmation.
const reported = activeShellCwd();
if (reported) { confirmed = true; return reported; }
if (refreshing) {
// Output may settle while a pre-cd query is still in flight. Keep one
// follow-up query so that its older result cannot be the last update.
refreshAgain = true;
return refreshing;
}
const pending = (async () => {
const cwd = await bounded(options.io.cwd());
check();
const reported = activeShellCwd();
const live = terminalCwdStore.getCwd(options.sessionId);
confirmed = true;
if (reported) return reported;
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
return cwd;
})();
refreshing = pending;
try { return await pending; } finally {
if (refreshing === pending) refreshing = null;
if (refreshAgain && current()) {
refreshAgain = false;
void refresh().catch(() => {});
}
}
}
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
ready();
if (changing) throw new DirectoryPickerError('busy');
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
if (!intent) throw new DirectoryPickerError('unsupportedPath');
changing = true;
let confirmationFinished = false;
try {
const canonical = await bounded(options.io.realpath(path, signal), signal);
check(signal);
ready(); // The shell can become busy while resolving a remote symlink.
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
options.io.write(intent.command + '\r');
accepted();
const deadline = Date.now() + (options.confirmationMs ?? 8000);
const hasFreshOsc7 = () => {
const cwd = terminalCwdStore.getCwd(options.sessionId);
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
&& (cwd === path || cwd === canonical);
};
const pause = () => new Promise<void>(resolve => {
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
const timer = setTimeout(done, 200);
lifetime.signal.addEventListener('abort', done, { once: true });
});
const confirmFromProbe = async () => {
while (!confirmationFinished && Date.now() < deadline) {
check(signal);
if (hasFreshOsc7()) return;
if (options.isReady()) {
try {
const cwd = await refresh(submittedRevision);
check(signal);
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
} catch (error) {
if (!current() || signal.aborted) throw error;
}
}
await pause();
}
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
};
// OSC 7 is authoritative even when process cwd lookup is unavailable or
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
const confirmFromOsc7 = async () => {
while (!confirmationFinished) {
check(signal);
if (hasFreshOsc7()) return;
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
await pause();
}
};
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
check(signal);
confirmed = true;
} finally { confirmationFinished = true; changing = false; }
}
return {
refresh: () => refresh(),
hasConfirmedCwd: () => confirmed,
async home() {
check();
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
check(); return result;
},
async list(path: string, signal: AbortSignal) {
check(signal);
const result = await bounded(options.io.list(path, signal), signal);
check(signal);
return result;
},
change,
dispose() { disposed = true; lifetime.abort(); },
};
}
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalDirectoryController } from './terminalDirectoryController';
import { terminalCwdStore } from './terminalCwdStore';
import {
observeTerminalDirectory, registerTerminalDirectory,
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
} from './terminalDirectoryRegistry';
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let cwd = '/before'; let reads = 0;
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
io: { cwd: async () => { reads++; return cwd; },
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
publish: value => { published.push(value); },
});
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
t.after(() => { stopObserving(); unregister(); });
cwd = '/after-manual-cd';
// Multiple prompt/output chunks produce just one read. No trusted prompt
// detection, command-completed callback, or parsed `cd` is involved.
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(80);
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
t.mock.timers.tick(149);
assert.equal(reads, 0);
t.mock.timers.tick(1);
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(published, ['/after-manual-cd']);
assert.equal(reads, 1);
t.mock.timers.tick(60_000);
assert.equal(reads, 1, 'no polling after output has settled');
});
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const id = 'nested-shell-output';
const published: string[] = [];
const controller = createTerminalDirectoryController({
sessionId: id, isCurrent: () => true, isReady: () => true,
// The PTY process stays in its original directory while a child shell
// runs in /. Its cwd is only a fallback for shells without OSC 7.
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} },
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
});
const unregister = registerTerminalDirectory(id, controller);
const stopObserving = observeTerminalDirectory(id);
t.after(() => { stopObserving(); unregister(); });
scheduleObservedTerminalDirectoryRefresh(id);
terminalCwdStore.setCwd(id, '/', 'osc7');
t.mock.timers.tick(150);
await new Promise(resolve => setImmediate(resolve));
assert.equal(terminalCwdStore.getCwd(id), '/');
// Later output chunks, prompt completion and reopening/manual refresh must
// not treat that same active-shell report as stale merely due to timing.
scheduleObservedTerminalDirectoryRefresh(id);
t.mock.timers.tick(150);
refreshObservedTerminalDirectory(id);
assert.equal(await controller.refresh(), '/');
assert.deepEqual(published, []);
assert.equal(terminalCwdStore.getSource(id), 'osc7');
});
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let reads = 0;
const controller = createTerminalDirectoryController({
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
realpath: async value => value, write() {} }, publish() {},
});
const unregister = registerTerminalDirectory('hidden-chips', controller);
t.after(unregister);
const stopObserving = observeTerminalDirectory('hidden-chips');
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
stopObserving();
t.mock.timers.tick(1000);
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
t.mock.timers.tick(1000);
await new Promise(resolve => setImmediate(resolve));
assert.equal(reads, 0);
});
@@ -0,0 +1,58 @@
import type { TerminalDirectoryController } from './terminalDirectoryController';
const sessions = new Map<string, TerminalDirectoryController>();
const consumers = new Map<string, number>();
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
const OUTPUT_SETTLE_MS = 150;
function cancelScheduledRefresh(id: string) {
const timer = scheduledRefreshes.get(id);
if (timer !== undefined) clearTimeout(timer);
scheduledRefreshes.delete(id);
}
const listeners = new Set<() => void>();
export const subscribeTerminalDirectories = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
cancelScheduledRefresh(id);
sessions.set(id, controller);
listeners.forEach(listener => listener());
return () => {
controller.dispose();
if (sessions.get(id) !== controller) return;
cancelScheduledRefresh(id);
sessions.delete(id);
listeners.forEach(listener => listener());
};
}
export function observeTerminalDirectory(id: string) {
consumers.set(id, (consumers.get(id) ?? 0) + 1);
return () => {
const next = (consumers.get(id) ?? 1) - 1;
if (next) consumers.set(id, next);
else { consumers.delete(id); cancelScheduledRefresh(id); }
};
}
/** Called by the existing command completion path, only while chips are visible. */
export function refreshObservedTerminalDirectory(id: string) {
cancelScheduledRefresh(id);
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
}
/**
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
* completion. Debounce actual output/submission activity instead of polling or
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
* the bound shell's process cwd only as a fallback.
*/
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
if (!consumers.has(id) || !sessions.has(id)) return;
cancelScheduledRefresh(id);
scheduledRefreshes.set(id, setTimeout(() => {
scheduledRefreshes.delete(id);
refreshObservedTerminalDirectory(id);
}, OUTPUT_SETTLE_MS));
}
@@ -3,6 +3,37 @@ import test from 'node:test';
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry'; import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
test('recovery snapshots use the current handler and disappear on unmount', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.inspect('s'), null);
const unregister = registry.register('s', () => {}, () => false,
() => ({ status: 'disconnected', waitingForInput: true, reconnecting: false }));
assert.equal(registry.inspect('s')?.waitingForInput, true);
assert.equal(registry.inspect('s')?.reconnectAllowed, false);
unregister();
assert.equal(registry.inspect('s'), null);
});
test('agent reconnect never queues an unmounted terminal', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.requestIfAvailable('missing'), false);
let calls = 0;
registry.register('missing', () => { calls++; });
assert.equal(calls, 0);
});
test('agent reconnect respects live eligibility and active reconnects', () => {
const registry = createTerminalReconnectRegistry();
let eligible = false;
let calls = 0;
registry.register('s', () => { calls++; registry.setActive('s', true); }, () => eligible);
assert.equal(registry.requestIfAvailable('s'), false);
eligible = true;
assert.equal(registry.requestIfAvailable('s'), true);
assert.equal(registry.requestIfAvailable('s'), false);
assert.equal(calls, 1);
});
test('a registered terminal session can be reconnected on request', () => { test('a registered terminal session can be reconnected on request', () => {
const registry = createTerminalReconnectRegistry(); const registry = createTerminalReconnectRegistry();
const requestedSessionIds: string[] = []; const requestedSessionIds: string[] = [];
+27 -1
View File
@@ -1,7 +1,10 @@
type TerminalReconnectHandler = () => void; type TerminalReconnectHandler = () => void;
export type TerminalRecoverySnapshot = { status: string; waitingForInput: boolean; reconnecting: boolean; stopped?: boolean };
export const createTerminalReconnectRegistry = () => { export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>(); const handlers = new Map<string, TerminalReconnectHandler>();
const eligibility = new Map<string, () => boolean>();
const recoveryReaders = new Map<string, () => TerminalRecoverySnapshot>();
const pendingRequests = new Set<string>(); const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>(); const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>(); const enterReconnectEnabledIds = new Set<string>();
@@ -28,19 +31,40 @@ export const createTerminalReconnectRegistry = () => {
return () => listeners.delete(listener); return () => listeners.delete(listener);
}; };
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => { const register = (sessionId: string, handler: TerminalReconnectHandler,
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => void) => {
handlers.set(sessionId, handler); handlers.set(sessionId, handler);
eligibility.set(sessionId, canReconnect);
if (readRecovery) recoveryReaders.set(sessionId, readRecovery);
else recoveryReaders.delete(sessionId);
if (pendingRequests.delete(sessionId)) { if (pendingRequests.delete(sessionId)) {
handler(); handler();
} }
return () => { return () => {
if (handlers.get(sessionId) === handler) { if (handlers.get(sessionId) === handler) {
handlers.delete(sessionId); handlers.delete(sessionId);
eligibility.delete(sessionId);
recoveryReaders.delete(sessionId);
setActive(sessionId, false); setActive(sessionId, false);
} }
}; };
}; };
const inspect = (sessionId: string) => {
const state = recoveryReaders.get(sessionId)?.();
if (!state) return null;
return { ...state, reconnecting: state.reconnecting || isActive(sessionId),
reconnectAllowed: !isActive(sessionId) && Boolean(eligibility.get(sessionId)?.()) };
};
// Agent requests must never survive unmount or bypass authentication/auto-retry.
const requestIfAvailable = (sessionId: string): boolean => {
const handler = handlers.get(sessionId);
if (!handler || isActive(sessionId) || !eligibility.get(sessionId)?.()) return false;
handler();
return true;
};
const request = (sessionId: string): boolean => { const request = (sessionId: string): boolean => {
if (isActive(sessionId)) return false; if (isActive(sessionId)) return false;
const handler = handlers.get(sessionId); const handler = handlers.get(sessionId);
@@ -66,7 +90,9 @@ export const createTerminalReconnectRegistry = () => {
return { return {
register, register,
inspect,
request, request,
requestIfAvailable,
requestEnterReconnect, requestEnterReconnect,
setEnterReconnectEnabled, setEnterReconnectEnabled,
setActive, setActive,
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin tools stay in the toolbar and retain placement across catalog changes and remount', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'localStorage', 'StorageEvent', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useTerminalSidePanelTabOrder, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER } = await import('./terminalSidePanelTabs');
const { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT } = await import('../../infrastructure/config/storageKeys');
const plugin = 'plugin-view:com.example.sidebar.page' as const;
dom.window.localStorage.setItem(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, JSON.stringify({ order: TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, placement: {} }));
let model: ReturnType<typeof useTerminalSidePanelTabOrder>;
let tools: typeof plugin[] = [];
function Fixture() { model = useTerminalSidePanelTabOrder(tools); return <div />; }
const root = createRoot(document.getElementById('root')!);
const render = async () => { await act(async () => { root.render(<Fixture />); }); };
try {
await render();
tools = [plugin]; await render();
assert.equal(model!.sidePanelTabOrder[1], plugin, 'new plugin is a visible toolbar item');
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).shown.includes(plugin));
await act(async () => { model!.setPlacement(plugin, 'collapse', [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]); });
tools = []; await render();
assert.ok(!model!.partition(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER).collapsed.includes(plugin));
tools = [plugin]; await render();
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin));
await act(async () => { root.unmount(); });
const nextRoot = createRoot(document.getElementById('root')!);
tools = []; await act(async () => { nextRoot.render(<Fixture />); });
tools = [plugin]; await act(async () => { nextRoot.render(<Fixture />); });
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin), 'cold catalog loading preserves preference');
await act(async () => { nextRoot.unmount(); });
} finally {
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
+40 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
normalizeToolbarItemLayout, normalizeToolbarItemLayout,
@@ -13,7 +13,7 @@ import {
} from '../../infrastructure/config/storageKeys'; } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout'; import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout'; import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool; export type TerminalSidePanelTabId = SidePanelTool;
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
migrateSidePanelTabLayoutIfNeeded(); migrateSidePanelTabLayoutIfNeeded();
export function useTerminalSidePanelTabOrder(): { export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
sidePanelTabOrder: TerminalSidePanelTabId[]; sidePanelTabOrder: TerminalSidePanelTabId[];
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void; setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
layout: ToolbarItemLayout; layout: ToolbarItemLayout;
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
resetLayout: () => void; resetLayout: () => void;
partition: ReturnType<typeof useToolbarItemLayout>['partition']; partition: ReturnType<typeof useToolbarItemLayout>['partition'];
} { } {
// Keep preferences for temporarily unavailable/disabled plugins, including
// across a cold start before the contribution catalog finishes loading.
const knownPluginTools = useRef<Set<string> | null>(null);
if (!knownPluginTools.current) {
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { stored = null; }
const order = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : [];
knownPluginTools.current = new Set(Array.isArray(order)
? order.filter((id): id is string => typeof id === 'string' && isPluginSidePanelTool(id)) : []);
}
for (const tool of pluginTools) if (isPluginSidePanelTool(tool)) knownPluginTools.current.add(tool);
const pluginKey = [...knownPluginTools.current].join('\0');
const defaults = useMemo<ToolbarItemLayoutDefaults>(() => {
const plugins = pluginKey ? pluginKey.split('\0') : [];
return { order: [TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER[0], ...plugins, ...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.slice(1)] };
}, [pluginKey]);
const { const {
layout, layout,
setPlacement, setPlacement,
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
partition, partition,
} = useToolbarItemLayout( } = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS, defaults,
); );
// Newly installed plugins get a visible slot near the start even when an
// older saved layout already exists. Existing choices always keep their order.
const activePluginKey = pluginTools.filter(isPluginSidePanelTool).join('\0');
useEffect(() => {
const active = activePluginKey ? activePluginKey.split('\0') : [];
if (!active.length || active.some((id) => !layout.order.includes(id))) return;
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { return; }
const storedOrder = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : null;
const saved = new Set(Array.isArray(storedOrder) ? storedOrder : []);
const fresh = active.filter((id) => !saved.has(id));
if (!fresh.length) return;
const rest = layout.order.filter((id) => !fresh.includes(id));
const index = Math.max(0, rest.indexOf('sftp') + 1);
const next = [...rest.slice(0, index), ...fresh, ...rest.slice(index)];
if (next.some((id, i) => id !== layout.order[i])) setOrder(next);
}, [activePluginKey, layout.order, setOrder]);
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[]; const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
const setSidePanelTabOrder = useCallback( const setSidePanelTabOrder = useCallback(
@@ -0,0 +1,215 @@
/**
* Minimal in-memory IndexedDB stub covering the exact subset used by
* `aiSessionIdbStorage.ts`: open + upgrade (createObjectStore), readonly
* getAll/get, readwrite put/delete/clear, and meta-store records. Not a
* general-purpose IDB implementation — extend deliberately if the wrapper
* grows new usage.
*/
type Resolve = () => void;
const microtask2 = (fn: Resolve) => {
queueMicrotask(() => queueMicrotask(fn));
};
export class FakeRequest<T> {
onsuccess: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onerror: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
onupgradeneeded: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
result: T;
error: Error | null = null;
constructor(result: T, mode: 'immediate' | 'upgrade' | 'failure' = 'immediate', error?: Error) {
this.result = result;
if (mode === 'failure') {
this.error = error ?? new Error('fake idb failure');
queueMicrotask(() => this.onerror?.(this));
return;
}
queueMicrotask(() => {
if (mode === 'upgrade') this.onupgradeneeded?.(this);
this.onsuccess?.(this);
});
}
}
interface StoreSpec {
name: string;
keyPath: string;
}
export class FakeObjectStore {
/** Instrumentation for per-record write assertions. */
putCount = 0;
deleteCount = 0;
constructor(
public readonly spec: StoreSpec,
public readonly records: Map<string, unknown>,
) {}
put(value: unknown): FakeRequest<IDBValidKey> {
this.putCount += 1;
const key = (value as Record<string, unknown>)[this.spec.keyPath] as string;
this.records.set(key, value);
return new FakeRequest(key);
}
delete(key: IDBValidKey): FakeRequest<undefined> {
this.deleteCount += 1;
this.records.delete(String(key));
return new FakeRequest(undefined);
}
clear(): FakeRequest<undefined> {
this.records.clear();
return new FakeRequest(undefined);
}
get(key: IDBValidKey): FakeRequest<unknown> {
return new FakeRequest(this.records.get(String(key)));
}
getAll(): FakeRequest<unknown[]> {
return new FakeRequest(Array.from(this.records.values()));
}
}
export class FakeTransaction {
oncomplete: Resolve | null = null;
onabort: ((ev?: unknown) => void) | null = null;
onerror: ((ev?: unknown) => void) | null = null;
error: Error | null = null;
constructor(
private readonly database: FakeIndexedDb,
private readonly storeName: string,
private readonly fail: boolean,
) {
microtask2(() => {
if (this.fail) {
this.error = new Error('fake transaction failure');
this.onerror?.(this);
this.onabort?.(this);
return;
}
this.oncomplete?.();
});
}
objectStore(name: string): FakeObjectStore {
if (name !== this.storeName) {
throw new Error(`fake idb: transaction opened for "${this.storeName}", not "${name}"`);
}
return this.database.getOrCreateStore(name);
}
}
export class FakeIndexedDb {
/** DOMStringList-like: the wrapper calls `.contains()` (browser IDB API). */
readonly objectStoreNames = new Set<string>() as Set<string> & { contains(name: string): boolean };
constructor() {
this.objectStoreNames.contains = (name: string) => this.objectStoreNames.has(name);
}
private readonly storeSpecs = new Map<string, string>();
private readonly records = new Map<string, Map<string, unknown>>();
/** Instrumentation per store: { put, delete } counters. */
readonly opCounts = new Map<string, { put: number; delete: number }>();
onclose: (() => void) | null = null;
onversionchange: (() => void) | null = null;
createObjectStore(name: string, options?: { keyPath?: string }) {
this.objectStoreNames.add(name);
this.storeSpecs.set(name, options?.keyPath ?? 'key');
return { name };
}
getOrCreateStore(name: string): FakeObjectStore {
if (!this.storeSpecs.has(name)) {
this.storeSpecs.set(name, 'key');
this.objectStoreNames.add(name);
}
if (!this.records.has(name)) this.records.set(name, new Map());
const spec = { name, keyPath: this.storeSpecs.get(name)! };
const store = new FakeObjectStore(spec, this.records.get(name)!);
const counts = this.opCounts.get(name) ?? { put: 0, delete: 0 };
// Aggregate counts lazily via proxy-free bookkeeping on each op.
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
counts.put += 1;
this.opCounts.set(name, counts);
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
counts.delete += 1;
this.opCounts.set(name, counts);
return originalDelete(key);
};
return store;
}
transaction(storeName: string, _mode?: string): FakeTransaction {
return new FakeTransaction(this, storeName, false);
}
close() {
this.onclose?.();
}
/** Direct read for assertions (bypasses the async wrapper). */
dumpStore(name: string): unknown[] {
return Array.from(this.records.get(name)?.values() ?? []);
}
}
export interface FakeIndexedDbHandle {
db: FakeIndexedDb;
openCount: number;
restore: () => void;
}
export function installFakeIndexedDb(options: { failOpen?: boolean; failOpenTimes?: number; onOperation?: (storeName: string, op: 'put' | 'delete') => void } = {}): FakeIndexedDbHandle {
const previous = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
const handle: FakeIndexedDbHandle = {
db: new FakeIndexedDb(),
openCount: 0,
restore: () => {
(globalThis as { indexedDB?: IDBFactory }).indexedDB = previous;
},
};
if (options.onOperation) {
const onOperation = options.onOperation;
const originalGet = handle.db.getOrCreateStore.bind(handle.db);
handle.db.getOrCreateStore = (name: string) => {
const store = originalGet(name);
const originalPut = store.put.bind(store);
store.put = (value: unknown) => {
onOperation(name, 'put');
return originalPut(value);
};
const originalDelete = store.delete.bind(store);
store.delete = (key: IDBValidKey) => {
onOperation(name, 'delete');
return originalDelete(key);
};
return store;
};
}
(globalThis as { indexedDB?: unknown }).indexedDB = {
open(_name: string, _version: number) {
handle.openCount += 1;
if (options.failOpen) {
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
if (options.failOpenTimes != null && handle.openCount <= options.failOpenTimes) {
// Fail only the first N opens, then recover (boot-retry scenarios).
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
}
return new FakeRequest<IDBDatabase>(handle.db as unknown as IDBDatabase, 'upgrade');
},
};
return handle;
}
+29
View File
@@ -12,6 +12,7 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT, STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS, STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH, STORAGE_KEY_AI_WEB_SEARCH,
@@ -34,6 +35,10 @@ import {
normalizeResponseIdleTimeoutSeconds, normalizeResponseIdleTimeoutSeconds,
} from '../../infrastructure/ai/types'; } from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup'; import { removeProviderReferences } from './aiProviderCleanup';
import {
DEFAULT_AI_SESSION_MESSAGE_CAP,
normalizeAiSessionMessageCap,
} from '../../domain/aiSessionMessageCap';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents'; import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots'; import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean'; import { useStoredBoolean } from './useStoredBoolean';
@@ -87,6 +92,12 @@ export function useAISettingsState() {
const [maxIterations, setMaxIterationsRaw] = useState<number>(() => const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20 localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
); );
const [aiSessionMessageCap, setAiSessionMessageCapRaw] = useState<number>(() =>
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
)
);
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() => const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
); );
@@ -197,6 +208,12 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(value); getAIBridge()?.aiMcpSetMaxIterations?.(value);
}, []); }, []);
const setAiSessionMessageCap = useCallback((value: number) => {
const normalizedValue = normalizeAiSessionMessageCap(value);
setAiSessionMessageCapRaw(normalizedValue);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP, normalizedValue);
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => { const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config); setWebSearchConfigRaw(config);
if (config) { if (config) {
@@ -277,6 +294,14 @@ export function useAISettingsState() {
getAIBridge()?.aiMcpSetMaxIterations?.(iters); getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break; break;
} }
case STORAGE_KEY_AI_SESSION_MESSAGE_CAP:
setAiSessionMessageCapRaw(
normalizeAiSessionMessageCap(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
),
);
break;
case STORAGE_KEY_AI_WEB_SEARCH: case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null); setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break; break;
@@ -340,6 +365,8 @@ export function useAISettingsState() {
setResponseIdleTimeout, setResponseIdleTimeout,
maxIterations, maxIterations,
setMaxIterations, setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig, webSearchConfig,
setWebSearchConfig, setWebSearchConfig,
quickMessages, quickMessages,
@@ -373,6 +400,8 @@ export function useAISettingsState() {
setResponseIdleTimeout, setResponseIdleTimeout,
maxIterations, maxIterations,
setMaxIterations, setMaxIterations,
aiSessionMessageCap,
setAiSessionMessageCap,
webSearchConfig, webSearchConfig,
setWebSearchConfig, setWebSearchConfig,
quickMessages, quickMessages,
@@ -0,0 +1,489 @@
/**
* Boot-scenario regression tests for the IndexedDB migration review fixes
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
* P2① baseline seeding after an IndexedDB failure,
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
* deletion.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import {
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
} from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { getAISessionsSnapshot } from './aiSessionsStore';
import {
__flushIdbWritesForTests,
__resetAiSessionsHydrationForTests,
__resetBackupMirrorForTests,
__resetIdbPersistenceForTests,
getPersistedAiSessionsSnapshot,
flushBackupMirror,
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
setLatestAIPanelViewByScopeSnapshot,
setLatestAISessionsSnapshot,
} from './aiStateSnapshots';
import {
idbDeleteSessionIds,
AI_SESSIONS_META_MIGRATION_KEY,
idbPutSessions,
loadOrMigrateAiSessions,
openAiSessionsIdb,
resetAiSessionsIdbForTests,
} from './aiSessionIdbStorage';
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
function makeSession(id: string, updatedAt: number): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'global' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as AISession;
}
function installDom() {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
return dom;
}
interface Mounted {
ai: ReturnType<typeof useAIState>;
root: ReturnType<typeof createRoot>;
container: HTMLDivElement;
}
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
const holder: { ai?: ReturnType<typeof useAIState> } = {};
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
const mountAi = React.useRef(ai);
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
return null;
}
function Harness() {
holder.ai = useAIState();
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => { root.render(React.createElement(Harness)); });
return { ai: holder.ai!, root, container };
}
async function unmount(mounted: Mounted, dom: JSDOM) {
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
dom.window.close();
}
function resetBootState() {
// Post-boot defaults first, then back to pending so every test starts
// from the pre-hydration state its own hook instance controls.
__resetIdbPersistenceForTests();
__resetAiSessionsHydrationForTests();
// Module-level snapshots leak across tests (the hook's initial state and
// the boot merge path read them) — reset to the cold-start state. The
// map snapshot must be null so the hook falls back to localStorage.
setLatestAISessionsSnapshot([]);
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
setLatestAIDraftsByScopeSnapshot({});
setLatestAIPanelViewByScopeSnapshot({});
}
test.beforeEach(() => {
__resetBackupMirrorForTests();
resetBootState();
});
test.afterEach(() => { __resetBackupMirrorForTests(); });
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const deleted = makeSession('deleted-chat', 1);
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
return { ok: true, json: JSON.stringify([deleted]) };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
const mounted = await mountAiState();
try {
flushBackupMirror();
await __flushIdbWritesForTests();
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
assert.equal(backupReads, 0);
assert.deepEqual(backupWrites, []);
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(idb.db.dumpStore('sessions'), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const backup = [makeSession('backup-chat', 1)];
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await __flushIdbWritesForTests();
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const chunk = makeSession('chunk-chat', 2);
const diskOnly = makeSession('disk-only-chat', 1);
const unmarked = makeSession('unmarked-deleted-chat', 0);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let recoveryAvailable = false;
let backupReads = 0;
const backupWrites: string[] = [];
Object.assign(dom.window, {
netcatty: {
aiSessionsBackupReadLatest: async () => {
backupReads += 1;
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
return { ok: false };
},
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
backupWrites.push(json);
return { ok: true };
},
},
});
let mounted = await mountAiState();
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
try {
assert.equal(backupReads, 1);
assert.equal(hasMarker(), false);
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
flushBackupMirror();
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
// Ordinary edits and unload must also preserve the recovery generation.
await act(async () => {
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
});
await new Promise(resolve => setTimeout(resolve, 600));
await __flushIdbWritesForTests();
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
assert.deepEqual(backupWrites, []);
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
assert.equal(updated.messages.length, 1);
assert.equal(hasMarker(), false);
await act(async () => { mounted.root.unmount(); });
mounted.container.remove();
resetBootState();
recoveryAvailable = true;
mounted = await mountAiState();
await __flushIdbWritesForTests();
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
assert.equal(hasMarker(), true);
const expected = [updated, chunk, diskOnly];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
flushBackupMirror();
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
}
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const stale: AISession = {
...makeSession('closed-chat', 1),
scope: { type: 'terminal', targetId: 'closed-terminal' },
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
};
const current: AISession = {
...stale,
updatedAt: 2,
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
};
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
await idbPutSessions(await openAiSessionsIdb(), [current]);
setLatestAISessionsSnapshot(null as unknown as AISession[]);
let snapshotAfterMaintenance: AISession[] | null | undefined;
let putsAfterMaintenance = 0;
const mounted = await mountAiState((ai) => {
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
snapshotAfterMaintenance = latestAISessionsSnapshot;
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
});
try {
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
await __flushIdbWritesForTests();
const expected = [{ ...current, scopeDangling: true }];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
const overflowJson = JSON.stringify(overflow);
const split = Math.floor(overflowJson.length / 2);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
const mounted = await mountAiState();
try {
const expected = [primary, ...overflow.slice(0, 2)];
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const primary = makeSession('primary-chat', 3);
const diskOnly = makeSession('disk-only-chat', 2);
const deleted = makeSession('deleted-chat', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
let backupReads = 0;
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
});
const mounted = await mountAiState();
try {
assert.equal(backupReads, 1);
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
await act(async () => {
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
});
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
const dom = installDom();
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
try {
// One open retry happened inside the boot effect: the legacy set now
// lives in IndexedDB (old code never retried and left the store empty).
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
assert.deepEqual(
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
['chat-a', 'chat-b'],
);
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
const dom = installDom();
const failing = installFakeIndexedDb({ failOpen: true });
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
const mounted = await mountAiState();
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
try {
// The retry open also failed: nothing was persisted, and the diff
// baseline must remain unset.
assert.deepEqual(failing.db.dumpStore('sessions'), []);
// IndexedDB recovers, then the user edits ONE session. The full set
// must reach the store, not only the edited record.
failing.restore();
resetAiSessionsIdbForTests();
recovered = installFakeIndexedDb();
await act(async () => {
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
});
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
await act(async () => {});
await __flushIdbWritesForTests();
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
assert.deepEqual(ids, ['chat-a', 'chat-b']);
} finally {
await unmount(mounted, dom);
if (recovered) recovered.restore();
resetAiSessionsIdbForTests();
}
});
// ── P2②: pointer validation waits for hydration ──
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
const dom = installDom();
const source = makeSession('chat-1', 1);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
JSON.stringify({ 'terminal:t1': 'chat-1' }),
);
const mounted = await mountAiState();
try {
// Old code nulled the pointer here: the validation effect ran against
// the initially-empty in-memory session array.
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
assert.equal(
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
'chat-1',
);
// After hydration the session is restored and the pointer is intact.
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
} finally {
await unmount(mounted, dom);
}
});
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
const dom = installDom();
installFakeIndexedDb({ failOpen: true });
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
const backup = [makeSession('backup-chat', 5)];
const warns: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warns.push(args); };
try {
Object.assign(dom.window, {
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
});
const mounted = await mountAiState();
try {
await act(async () => {});
// The backup generation became the recovery source (old code: the
// failing IndexedDB recheck rejection was swallowed and the restore
// never ran).
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
// The unopenable-store error was surfaced, not swallowed.
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
} finally {
await unmount(mounted, dom);
}
} finally {
console.warn = originalWarn;
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
resetAiSessionsIdbForTests();
}
});
// ── P2④: an empty getAll after hydration is an intentional deletion ──
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
const dom = installDom();
const idb = installFakeIndexedDb();
const source = makeSession('chat-1', 1);
await idbPutSessions(await openAiSessionsIdb(), [source]);
const mounted = await mountAiState();
try {
await act(async () => {});
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
// Another window deletes the last chat and announces the change. The
// announcement must come from a second BroadcastChannel: a channel never
// delivers to itself (the module-level channel is the receiving window).
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
await act(async () => {
otherWindow.postMessage(Date.now());
await new Promise((resolve) => setTimeout(resolve, 25));
});
otherWindow.close();
// Old code early-returned on the empty getAll and kept the deleted
// session alive locally.
assert.deepEqual(getAISessionsSnapshot().sessions, []);
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
} finally {
await unmount(mounted, dom);
idb.restore();
resetAiSessionsIdbForTests();
}
});
+11 -4
View File
@@ -8,7 +8,7 @@ import type { AISession } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts'; import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts'; import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts'; import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts'; import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SOURCE_SESSION: AISession = { const SOURCE_SESSION: AISession = {
id: 'chat-source', id: 'chat-source',
@@ -121,11 +121,18 @@ for (const newerCount of [49, 50]) {
let branch: AISession | null = null; let branch: AISession | null = null;
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); }); await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
assert.ok(branch); assert.ok(branch);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!); // Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
assert.equal(saved.length, 50); // nothing is evicted at these sizes) — not in a 50-capped localStorage
// blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages); assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2)); assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => root.unmount()); container.remove(); dom.window.close(); await act(async () => root.unmount()); container.remove();
// Reload from the read-only legacy store (the stand-in for what the
// migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
const reloaded = await setupAiState(saved); const reloaded = await setupAiState(saved);
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4); assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2); assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { useAISessionsStore } from './aiSessionsStore';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
for (const enabled of [true, false]) {
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession = {
id: 'long-chat', title: 'Long chat', agentId: 'codex', externalSessionId: 'native-thread',
scope: { type: 'global' }, createdAt: 1, updatedAt: 501,
messages: [{ id: 'system', role: 'system', content: 'Instruction', timestamp: 0 },
...Array.from({ length: 500 }, (_, index) => ({ id: `message-${index}`, role: 'user' as const, content: `Prompt ${index}`, timestamp: index + 1 }))],
};
setLatestAISessionsSnapshot([source]);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
let ai: ReturnType<typeof useAIState>;
let sessions: readonly AISession[] = [];
function Harness() {
ai = useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
await act(async () => {
ai!.addMessageToSession(source.id, { id: 'new-reply', role: 'assistant', content: 'Continued', timestamp: 502 });
});
const updated = sessions.find(value => value.id === source.id)!;
assert.equal(updated.messages.length, enabled ? 502 : 500);
assert.equal(updated.messages[0].id, 'system');
assert.equal(updated.messages[1].id, enabled ? 'message-0' : 'message-2');
assert.equal(updated.messages.at(-1)?.id, 'new-reply');
assert.equal(updated.externalSessionId, 'native-thread');
} finally {
await act(async () => { root.unmount(); });
container.remove();
dom.window.close();
}
});
}
+403 -27
View File
@@ -14,12 +14,15 @@ import {
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT, STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS, STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP, STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_AGENT_THINKING_MAP, STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH, STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES, STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys'; } from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages'; import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages'; import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
@@ -63,12 +66,23 @@ import {
bumpDraftUploadGeneration, bumpDraftUploadGeneration,
cleanupDeletedAIChatSessions, cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions, cleanupOrphanedAISessions,
flushBackupMirror,
getAIBridge, getAIBridge,
getDraftUploadGeneration, getDraftUploadGeneration,
getPersistedAiSessionsSnapshot,
isAiSessionsHydrationComplete,
latestAIActiveSessionMapSnapshot, latestAIActiveSessionMapSnapshot,
latestAIActiveTargetIdsSnapshot,
latestAIDraftsByScopeSnapshot, latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot, latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot, latestAISessionsSnapshot,
markAiSessionsHydrationComplete,
prepareRestoredSessions,
readAiSessionMessageCap,
rebindActiveSessionMap,
scheduleBackupMirror,
seedLastPersistedSessions,
setAiSessionsBackupRecoveryPending,
writeSessionsForStorage, writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot, setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot, setLatestAIDraftsByScopeSnapshot,
@@ -77,6 +91,26 @@ import {
type DraftsByScope, type DraftsByScope,
type PanelViewByScope, type PanelViewByScope,
} from './aiStateSnapshots'; } from './aiStateSnapshots';
import {
AI_SESSIONS_IDB_VERSION,
AI_SESSIONS_META_LAST_WRITE_KEY,
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
idbGetAllSessions,
idbPutMeta,
idbPutSessions,
loadOrMigrateAiSessions,
mirrorActiveSessionMapToIdb,
openAiSessionsIdb,
publishAiSessionsIdbChange,
readActiveSessionMapFromIdb,
subscribeAiSessionsIdbChange,
} from './aiSessionIdbStorage';
import {
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
} from './aiSessionsBackup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents'; import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { import {
persistCommandBlocklistSetting, persistCommandBlocklistSetting,
@@ -166,11 +200,207 @@ export function useAIState() {
); );
// ── Sessions ── // ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() => // Memory-only initial state: the primary store is IndexedDB and loads
latestAISessionsSnapshot // asynchronously in the boot effect below (issue #4123). The legacy
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) // localStorage key is read-only fallback and must not pre-seed stale data.
?? [] const [sessions, setSessionsRaw] = useState<AISession[]>(() => latestAISessionsSnapshot ?? []);
); // Issue #4125 review P2②: flips true when the boot load settles. The
// active-pointer validation effect waits for it — the initially-empty
// in-memory session array must never null the persisted pointers.
const [sessionsHydrated, setSessionsHydrated] = useState(() => isAiSessionsHydrationComplete());
// An explicit empty array is authoritative, even if the backup is stale.
// Only a missing or corrupt store can be restored from disk.
useEffect(() => {
window.addEventListener('beforeunload', flushBackupMirror);
return () => window.removeEventListener('beforeunload', flushBackupMirror);
}, []);
useEffect(() => {
let cancelled = false;
let cleanupAfterHydration = false;
void (async () => {
try {
// 1. IndexedDB (primary) → one-time legacy localStorage migration.
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
setAiSessionsBackupRecoveryPending(diskOnlyIds.size > 0);
const boot = await loadOrMigrateAiSessions(
async () => {
// Legacy retention split history between the primary key, JSON
// chunks, and explicitly marked disk-only records. Resolve all
// slices before the one-shot migration records completion.
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const key = index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
const chunk = localStorageAdapter.readString(key);
if (chunk == null) break;
chunks.push(chunk);
}
let legacy = mergeImportedSessions(
readLiveSessionsArray(chunks.join('')),
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
);
if (diskOnlyIds.size > 0) {
try {
const backup = await getAIBridge()?.aiSessionsBackupReadLatest?.();
if (backup?.ok) {
legacy = mergeImportedSessions(
(parseBackupSessions(backup.json) ?? []).filter(session => diskOnlyIds.has(session.id)),
legacy,
);
}
} catch (error) {
console.warn('[AIState] Disk-only session recovery failed; migration will retry on the next boot:', error);
}
}
return JSON.stringify(legacy);
},
[...diskOnlyIds],
);
setAiSessionsBackupRecoveryPending(boot.migrationPending);
if (cancelled) return;
if (boot.idbFailed && boot.sessions.length > 0) {
// Issue #4125 review P2①: the legacy set is not in IndexedDB yet.
// Persist it best-effort (one re-open retry) BEFORE seeding the
// diff baseline; if the store is still unreachable, leave the
// baseline unset so the next edit performs a full write instead of
// a diff that would drop the un-persisted sessions after IndexedDB
// recovers.
try {
const db = await openAiSessionsIdb();
if (!cancelled) {
await idbPutSessions(db, boot.sessions);
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
publishAiSessionsIdbChange();
seedLastPersistedSessions(boot.sessions);
}
} catch (error) {
console.warn(
'[AIState] Could not persist the legacy sessions after an IndexedDB failure;'
+ ' the diff baseline stays unset so the next edit rewrites every record:',
error,
);
}
} else {
seedLastPersistedSessions(boot.sessions);
}
// Restore the mirrored active-pointer map when the localStorage key
// is missing (fresh profile restored from IndexedDB).
if (localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) == null) {
const mirrored = await readActiveSessionMapFromIdb();
if (cancelled) return;
if (mirrored && Object.keys(mirrored).length > 0) {
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, mirrored);
setLatestAIActiveSessionMapSnapshot(mirrored);
setActiveSessionIdMapRaw(mirrored);
}
}
const bootSessions = boot.sessions;
if (bootSessions.length > 0) {
const live = latestAISessionsSnapshot ?? sessionsRef.current;
if (live.length > 0) {
// Sessions were created before the async load finished: merge
// (live records win by id) instead of clobbering either side.
const byId = new Map(bootSessions.map(session => [session.id, session]));
for (const session of live) byId.set(session.id, session);
const merged = Array.from(byId.values())
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
setLatestAISessionsSnapshot(merged);
sessionsRef.current = merged;
setSessionsRaw(merged);
writeSessionsForStorage(merged);
return;
}
setLatestAISessionsSnapshot(bootSessions);
sessionsRef.current = bootSessions;
setSessionsRaw(bootSessions);
scheduleBackupMirror(JSON.stringify(bootSessions));
cleanupAfterHydration = true;
return;
}
// A previously initialized empty IDB is a committed deletion. An
// incomplete migration must retry only its marked disk-only records.
if (boot.authoritativeEmpty || boot.migrationPending) return;
// 2. Uninitialized store (and no legacy data to migrate): the #4018 disk
// backup remains the last restore step. Only when the legacy store
// is also missing or corrupt — a valid empty legacy array is an
// intentional wipe and stays authoritative.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
const backup = await bridge.aiSessionsBackupReadLatest?.();
if (cancelled || !backup?.ok) return;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
// Issue #4125 review P2③: one open retry already happened inside
// the boot load. If the store is still unopenable, do not let the
// recheck rejection reach the outer catch — the disk backup below
// is the recovery source.
try {
if ((await idbGetAllSessions()).length > 0) return;
} catch (error) {
console.warn(
'[AIState] IndexedDB recheck failed during the disk-backup restore;'
+ ' applying the newest backup generation as the recovery source:',
error,
);
}
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
if (restored.length === 0) return;
// Apply restore synchronously (not inside a setState updater). A
// deferred updater would leave didRestore false and skip dangling
// marking even when the records later land in React state.
setLatestAISessionsSnapshot(restored);
writeSessionsForStorage(restored);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
cleanupAfterHydration = true;
const nextSessions = latestAISessionsSnapshot ?? restored;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
console.info(
`[AIState] Restored ${nextSessions.length} AI session(s) from the disk backup.`,
);
} catch (error) {
// Boot load must never break startup — but it must not vanish
// silently either (issue #4125 review P2③).
console.warn('[AIState] AI session boot load failed:', error);
} finally {
markAiSessionsHydrationComplete();
if (!cancelled) {
// Maintenance may have mounted before hydration with unchanged live
// targets. Replay it now against the authoritative boot history.
if (cleanupAfterHydration) {
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? sessionsRef.current;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
}
setSessionsHydrated(true);
}
}
})();
return () => {
cancelled = true;
};
}, []);
// Ref that always holds the latest sessions for use inside debounced callbacks // Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions); const sessionsRef = useRef(sessions);
useEffect(() => { useEffect(() => {
@@ -233,6 +463,9 @@ export function useAIState() {
useEffect(() => { useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap); setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
// Issue #4123: mirror the active-pointer map into the IndexedDB meta
// store (localStorage stays written at the mutation sites as before).
void mirrorActiveSessionMapToIdb(activeSessionIdMap);
}, [activeSessionIdMap]); }, [activeSessionIdMap]);
useEffect(() => { useEffect(() => {
@@ -244,6 +477,11 @@ export function useAIState() {
}, [panelViewByScope]); }, [panelViewByScope]);
useEffect(() => { useEffect(() => {
// Issue #4125 review P2②: before boot hydration settles, `sessions` is
// the initially-empty in-memory array; validating pointers against it
// would null every pointer and break workspace restore. No pointer
// writes before hydration completes.
if (!sessionsHydrated) return;
const validSessionIds = new Set<string>(sessions.map((session) => session.id)); const validSessionIds = new Set<string>(sessions.map((session) => session.id));
let changed = false; let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {}; const nextActiveSessionIdMap: Record<string, string | null> = {};
@@ -272,7 +510,7 @@ export function useAIState() {
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE); emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next; return next;
}); });
}, [sessions, activeSessionIdMap]); }, [sessions, activeSessionIdMap, sessionsHydrated]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => { const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
let nextActiveSessionIdMap: Record<string, string | null> | null = null; let nextActiveSessionIdMap: Record<string, string | null> | null = null;
@@ -464,7 +702,8 @@ export function useAIState() {
useEffect(() => { useEffect(() => {
const handleStorage = (e: StorageEvent) => { const handleStorage = (e: StorageEvent) => {
try { try {
switch (e.key) { const key = e.key;
switch (key) {
case STORAGE_KEY_AI_PROVIDERS: { case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS); const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) { if (parsed != null && !Array.isArray(parsed)) {
@@ -556,7 +795,10 @@ export function useAIState() {
break; break;
} }
case STORAGE_KEY_AI_SESSIONS: { case STORAGE_KEY_AI_SESSIONS: {
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []; // Session writes no longer land in localStorage (issue #4123);
// the cross-window signal is the IndexedDB change channel and the
// storage event only fires as a legacy fallback.
const nextSessions = getPersistedAiSessionsSnapshot();
setLatestAISessionsSnapshot(nextSessions); setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions); setSessionsRaw(nextSessions);
break; break;
@@ -598,8 +840,7 @@ export function useAIState() {
case STORAGE_KEY_AI_SESSIONS: case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw( setSessionsRaw(
latestAISessionsSnapshot latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? getPersistedAiSessionsSnapshot(),
?? [],
); );
return; return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
@@ -626,6 +867,37 @@ export function useAIState() {
}; };
}, []); }, []);
// Cross-window AI session sync: session writes land in IndexedDB now, so
// the localStorage `storage` event no longer fires for them. Other windows
// announce their writes on a BroadcastChannel; the receiver reloads the
// store from IndexedDB.
useEffect(() => subscribeAiSessionsIdbChange(() => {
void (async () => {
try {
const next = await idbGetAllSessions();
if (next.length === 0) {
if (!isAiSessionsHydrationComplete()) return;
// Issue #4125 review P2④: after hydration an empty store is an
// intentional cross-window deletion — adopt it instead of keeping
// the deleted session alive locally (its next edit would
// resurrect it). The pointer-validation effect clears the
// now-stale pointers. Only the pre-hydration window keeps the
// early-return: the boot load owns the store view until it
// settles.
setLatestAISessionsSnapshot([]);
seedLastPersistedSessions([]);
setSessionsRaw([]);
return;
}
setLatestAISessionsSnapshot(next);
seedLastPersistedSessions(next);
setSessionsRaw(next);
} catch {
// Cross-window refresh is best-effort.
}
})();
}), []);
// ── Sync initial safety settings to MCP Server on mount ── // ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => { useEffect(() => {
const bridge = getAIBridge(); const bridge = getAIBridge();
@@ -651,8 +923,8 @@ export function useAIState() {
}, []); }, []);
// ── Session CRUD ── // ── Session CRUD ──
const persistSessions = useCallback((next: AISession[]) => { const persistSessions = useCallback((next: AISession[], deletedSessionIds?: readonly string[]) => {
writeSessionsForStorage(next); writeSessionsForStorage(next, { deletedSessionIds });
}, []); }, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming) // Debounced version of persistSessions for high-frequency updates (e.g. streaming)
@@ -797,7 +1069,7 @@ export function useAIState() {
setSessionsRaw(prev => { setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId); const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next); setLatestAISessionsSnapshot(next);
persistSessions(next); persistSessions(next, [sessionId]);
return next; return next;
}); });
if (scopeKey) { if (scopeKey) {
@@ -841,7 +1113,7 @@ export function useAIState() {
return !(s.scope.type === scopeType && s.scope.targetId === targetId); return !(s.scope.type === scopeType && s.scope.targetId === targetId);
}); });
setLatestAISessionsSnapshot(next); setLatestAISessionsSnapshot(next);
persistSessions(next); persistSessions(next, removedSessionIds);
return next; return next;
}); });
const scopeKey = `${scopeType}:${targetId}`; const scopeKey = `${scopeType}:${targetId}`;
@@ -866,6 +1138,100 @@ export function useAIState() {
}); });
}, [persistSessions]); }, [persistSessions]);
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
// Rewrites `scope`, clears the dangling marker and makes the chat the
// active session of its new scope so it shows in the scoped history again.
const rebindSessionScope = useCallback((
sessionId: string,
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
) => {
const scopeKey = `${scope.type}:${scope.targetId}`;
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
const { scopeDangling: _cleared, ...rest } = s;
// Replace scope entirely (including hostIds) so history matching follows
// the destination terminal/workspace rather than the chat's old host.
const nextScope: AISession['scope'] = {
type: scope.type,
targetId: scope.targetId,
};
if (scope.hostIds && scope.hostIds.length > 0) {
nextScope.hostIds = [...scope.hostIds];
}
return {
...rest,
scope: nextScope,
updatedAt: Date.now(),
} as AISession;
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
// Drop stale pointers that still reference this chat under its previous
// scope key, then restore the active pointer for the new scope.
// Compute + persist inside the updater so a deferred React setState still
// writes the cleaned map (outer capture would stay null and skip persist).
setActiveSessionIdMapRaw(prev => {
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
// Open the rebound chat in this panel; active-map alone loses to an
// explicit draft/session panelView that still points elsewhere.
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
}, [persistSessions, setPanelViewByScope]);
// Manual durability escape hatch (issue #4015 follow-up): export the
// current store to a user-chosen JSON file, or import one back in.
// Import is an explicit action, so it may overwrite the live store.
const exportSessionsBackup = useCallback(async (): Promise<string | null> => {
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupExport) return null;
const result = await bridge.aiSessionsBackupExport({
json: JSON.stringify(sessionsRef.current),
});
return result?.success ? (result.filePath ?? '') : null;
}, []);
const importSessionsBackup = useCallback(async (): Promise<number> => {
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupImport) return 0;
const result = await bridge.aiSessionsBackupImport();
if (!result?.success || !result.json) return 0;
const imported = parseBackupSessions(result.json);
if (!imported || imported.length === 0) return 0;
// Merge synchronously against the live ref so orphan cleanup sees the
// imported chats even if React would defer a setState updater.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
if (!writeSessionsForStorage(next)) return 0;
const importedIds = new Set(imported.map(session => session.id));
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
setSessionsRaw(next);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Import does not change live terminals/workspaces, so the orphan-cleanup
// effect will not run. Reuse the last known live target set (empty is
// valid: nothing live) to mark chats bound to missing scopes as dangling
// so /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions =
latestAISessionsSnapshot
?? next;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return nextSessions.filter(session => importedIds.has(session.id)).length;
}, []);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => { const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
setSessionsRaw(prev => { setSessionsRaw(prev => {
const next = prev.map(s => ( const next = prev.map(s => (
@@ -879,21 +1245,27 @@ export function useAIState() {
}); });
}, [debouncedPersistSessions]); }, [debouncedPersistSessions]);
// Maximum messages per session to prevent unbounded memory growth // Local-only retention. Synced history must match the full-history storage
const MAX_MESSAGES_PER_SESSION = 500; // policy. The in-memory bound follows the user-configurable per-session cap
// (see aiStateSnapshots.readAiSessionMessageCap) but never below this floor,
// so the default behavior is unchanged and RAM stays bounded when the user
// lowers the persisted cap.
const MIN_IN_MEMORY_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => { const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
const inMemoryCap = Math.max(readAiSessionMessageCap(), MIN_IN_MEMORY_MESSAGES_PER_SESSION);
setSessionsRaw(prev => { setSessionsRaw(prev => {
const next = prev.map(s => { const next = prev.map(s => {
if (s.id !== sessionId) return s; if (s.id !== sessionId) return s;
let msgs = [...s.messages, message]; let msgs = [...s.messages, message];
// Trim oldest messages if exceeding limit (keep system messages) // Dropping synced prefixes makes the next cloud merge add them back.
if (msgs.length > MAX_MESSAGES_PER_SESSION) { if (!syncEnabled && msgs.length > inMemoryCap) {
const systemMsgs = msgs.filter(m => m.role === 'system'); const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system'); const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length); const dropped = nonSystemMsgs.length - (inMemoryCap - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`); console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${inMemoryCap} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)]; msgs = [...systemMsgs, ...nonSystemMsgs.slice(-inMemoryCap + systemMsgs.length)];
} }
return { ...s, messages: msgs, updatedAt: Date.now() }; return { ...s, messages: msgs, updatedAt: Date.now() };
}); });
@@ -1131,11 +1503,11 @@ export function useAIState() {
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => { const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
cleanupOrphanedAISessions(activeTargetIds); cleanupOrphanedAISessions(activeTargetIds);
if (!isAiSessionsHydrationComplete()) return;
const nextSessions = const nextSessions =
latestAISessionsSnapshot latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? getPersistedAiSessionsSnapshot();
?? [];
sessionsRef.current = nextSessions; sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions); setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw( setActiveSessionIdMapRaw(
@@ -1187,8 +1559,7 @@ export function useAIState() {
?? {}; ?? {};
const currentSessions = const currentSessions =
latestAISessionsSnapshot latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? getPersistedAiSessionsSnapshot();
?? [];
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {}; const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const result = handoffDissolvedWorkspaceAIScope({ const result = handoffDissolvedWorkspaceAIScope({
activeSessionIdMap: currentMap, activeSessionIdMap: currentMap,
@@ -1233,8 +1604,7 @@ export function useAIState() {
?? {}; ?? {};
const currentSessions = const currentSessions =
latestAISessionsSnapshot latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? getPersistedAiSessionsSnapshot();
?? [];
const result = retargetWorkspaceActiveChatAfterMemberLoss({ const result = retargetWorkspaceActiveChatAfterMemberLoss({
activeSessionIdMap: currentMap, activeSessionIdMap: currentMap,
sessions: currentSessions, sessions: currentSessions,
@@ -1370,6 +1740,9 @@ export function useAIState() {
deleteSession, deleteSession,
deleteSessionsByTarget, deleteSessionsByTarget,
updateSessionTitle, updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId, updateSessionExternalSessionId,
addMessageToSession, addMessageToSession,
updateLastMessage, updateLastMessage,
@@ -1432,6 +1805,9 @@ export function useAIState() {
deleteSession, deleteSession,
deleteSessionsByTarget, deleteSessionsByTarget,
updateSessionTitle, updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId, updateSessionExternalSessionId,
addMessageToSession, addMessageToSession,
updateLastMessage, updateLastMessage,
@@ -9,7 +9,7 @@ import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRunt
import { useAIState } from './useAIState.ts'; import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts'; import { useAISessionsStore } from './aiSessionsStore.ts';
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts'; import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts'; import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a'; const SCOPE_KEY = 'terminal:terminal-a';
@@ -175,17 +175,21 @@ for (const newerCount of [49, 50]) {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId; branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
}); });
assert.ok(branchId); assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!); // Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
assert.equal(saved.length, 50); // nothing is evicted at these sizes) — not in a 50-capped localStorage blob.
const saved: AISession[] = getPersistedAiSessionsSnapshot();
assert.equal(saved.length, newerCount + 2);
const original = saved.find(item => item.id === SOURCE_SESSION.id); const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning'); assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION); assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2)); assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt); assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount()); await act(async () => root.unmount());
container.remove(); dom.window.close(); container.remove();
// Seed the read-only legacy store with the persisted set (the stand-in
// Remount the actual hook from the persisted records, as on app reload. // for what the migration persists on a profile without IndexedDB).
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
dom.window.close();
const reloaded = await setupAiState(saved); const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages); assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2)); assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
@@ -223,7 +227,7 @@ for (const viewingScope of [
), 3); ), 3);
const original = capture.sessions.find(item => item.id === source.id)!; const original = capture.sessions.find(item => item.id === source.id)!;
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore); assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[]; const saved = getPersistedAiSessionsSnapshot();
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope); assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
await act(async () => root.unmount()); await act(async () => root.unmount());
container.remove(); dom.window.close(); container.remove(); dom.window.close();
@@ -11,6 +11,10 @@ import {
} from "./useActiveChromeTheme.ts"; } from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts"; import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import {
getSettingsChromeSnapshot,
publishSettingsChromeSnapshot,
} from "./settingsChromeStore.ts";
function createInlineStyle() { function createInlineStyle() {
const values = new Map<string, string>(); const values = new Map<string, string>();
@@ -98,6 +102,193 @@ test("syncActiveChromeTheme skips surface refresh when the active theme fingerpr
} }
}); });
for (const preference of ["system", "light", "dark"] as const) {
for (const chromeType of ["light", "dark"] as const) {
test(`syncActiveChromeTheme uses ${preference === "system" ? "system" : chromeType} native theme for ${preference} preference and ${chromeType} chrome`, () => {
const globals = globalThis as typeof globalThis & { document?: Document; window?: Window };
const originalDocument = globals.document;
const originalWindow = globals.window;
const originalSnapshot = getSettingsChromeSnapshot();
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === chromeType);
assert.ok(theme);
const themeCalls: string[] = [];
const backgroundCalls: string[] = [];
globals.document = {
documentElement: {
dataset: {},
classList: { remove: () => {}, add: () => {} },
removeAttribute: () => {},
},
getElementById: () => null,
createElement: () => ({}),
head: { appendChild: () => {} },
querySelector: () => null,
} as unknown as Document;
globals.window = {
netcatty: {
setTheme: (value: string) => themeCalls.push(value),
setBackgroundColor: (value: string) => backgroundCalls.push(value),
},
} as unknown as Window & typeof globalThis;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: preference });
syncActiveChromeTheme(theme, () => {
throw new Error("app theme should not be restored for an active chrome theme");
});
assert.deepEqual(themeCalls, [preference === "system" ? "system" : chromeType]);
assert.deepEqual(backgroundCalls, [theme.colors.background]);
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
if (originalDocument) {
globals.document = originalDocument;
} else {
delete globals.document;
}
if (originalWindow) {
globals.window = originalWindow;
} else {
delete globals.window;
}
}
});
}
}
// A hanging jsdom/React render must fail this test quickly instead of
// consuming the whole CI job budget.
test("terminal chrome never replaces the application native theme policy", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const themeSources: string[] = [];
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: async (source: string) => { themeSources.push(source); return true; },
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
assert.deepEqual(themeSources, ['system', 'system'], 'terminal colors must not pin nativeTheme.themeSource to light/dark');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("system preference refresh reapplies an unchanged independent terminal chrome", { timeout: 15000 }, async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
testWindow.netcatty = {
setTheme: (source: string) => {
assert.equal(source, 'system', 'chrome refresh must preserve native system theme policy');
return Promise.resolve(true);
},
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
} as unknown as NetcattyBridge;
globalThis.window = testWindow;
globalThis.document = testWindow.document;
try {
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
for (const type of ['light', 'dark'] as const) {
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
assert.ok(theme);
syncActiveChromeTheme(theme, () => {});
// The application settings effect runs first on an OS preference change.
document.documentElement.className = type === 'dark' ? 'light' : 'dark';
backgrounds.push('application-background');
syncActiveChromeTheme(theme, () => {}, { refreshSurfaces: true });
assert.equal(document.documentElement.classList.contains(type), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
}
syncActiveChromeTheme(null, () => {
document.documentElement.className = 'light';
backgrounds.push('application-background');
});
assert.equal(document.documentElement.className, 'light');
assert.equal(document.documentElement.dataset.activeChromeTheme, undefined);
assert.equal(document.getElementById('netcatty-active-chrome-theme'), null);
assert.equal(backgrounds.at(-1), 'application-background');
} finally {
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("chrome hook reacts to OS preference changes with stable terminal props", { timeout: 15000 }, async () => {
const { JSDOM } = await import('jsdom');
const { act, createElement } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useActiveChromeTheme } = await import('./useActiveChromeTheme');
const { getSettingsChromeSnapshot, publishSettingsChromeSnapshot } = await import('./settingsChromeStore');
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalSnapshot = getSettingsChromeSnapshot();
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const originalActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
const backgrounds: string[] = [];
const testWindow = dom.window as unknown as Window & typeof globalThis;
globalThis.window = testWindow;
globalThis.document = dom.window.document;
testWindow.netcatty = {
setBackgroundColor: async (color: string) => { backgrounds.push(color); return true; },
} as unknown as NetcattyBridge;
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === 'dark');
assert.ok(theme);
const applyAppTheme = () => {};
function Harness() {
useActiveChromeTheme({ activeTheme: theme!, applyAppTheme });
return null;
}
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'dark' });
let renderer: ReturnType<typeof createRoot> | undefined;
try {
await act(async () => {
renderer = createRoot(document.createElement('div'));
renderer.render(createElement(Harness));
});
assert.equal(document.documentElement.classList.contains('dark'), true);
document.documentElement.className = 'light';
backgrounds.push('application-background');
await act(async () => {
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'light' });
});
assert.equal(document.documentElement.classList.contains('dark'), true);
assert.equal(backgrounds.at(-1), theme.colors.background);
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
publishSettingsChromeSnapshot(originalSnapshot);
globalThis.window = originalWindow;
globalThis.document = originalDocument;
dom.window.close();
}
});
test("active chrome theme foreground uses contrast calculation", () => { test("active chrome theme foreground uses contrast calculation", () => {
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8"); const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
+12 -6
View File
@@ -1,6 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry'; import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime'; import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { useSettingsChromeStore } from './settingsChromeStore'; import { getSettingsChromeSnapshot, useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react"; import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models"; import type { TerminalTheme } from "../../domain/models";
import { import {
@@ -237,9 +237,11 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
} }
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) { function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
netcattyBridge.get()?.setTheme?.(targetClass); // Preserve OS preference events instead of locking nativeTheme to the chrome type.
const targetClass = theme.type === "dark" ? "dark" : "light";
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background); netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
} }
applyTopTabsChromeThemeVars(theme); applyTopTabsChromeThemeVars(theme);
@@ -248,10 +250,11 @@ function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
export function syncActiveChromeTheme( export function syncActiveChromeTheme(
activeTheme: TerminalTheme | null, activeTheme: TerminalTheme | null,
applyAppTheme: () => void, applyAppTheme: () => void,
{ refreshSurfaces = false }: { refreshSurfaces?: boolean } = {},
): void { ): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null; const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint(); const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) { if (nextFingerprint === appliedFingerprint && !(activeTheme && refreshSurfaces)) {
return; return;
} }
@@ -281,8 +284,11 @@ export function useActiveChromeTheme({
applyAppThemeRef.current = applyAppTheme; applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => { useLayoutEffect(() => {
syncActiveChromeTheme(effectiveTheme, applyAppTheme); // Settings may have just applied the OS theme over a fixed terminal theme.
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]); // Reassert its visual surfaces even when the terminal fingerprint is stable;
// tab-switch callers retain the default fingerprint fast path.
syncActiveChromeTheme(effectiveTheme, applyAppTheme, { refreshSurfaces: true });
}, [effectiveTheme, applyAppTheme, pluginThemeRevision, resolvedTheme]);
useLayoutEffect(() => { useLayoutEffect(() => {
return () => { return () => {
Loaded 100 of 892 files, more files were not shown because too many files have changed in this diff. Show more