Compare commits

...

73 Commits

Author SHA1 Message Date
陈大猫
5bc5a6c8b2 fix: address Codex follow-up review on PR #720 (#723)
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: address Codex follow-up review on PR #720

Two issues surfaced by Codex's post-merge review of PR #720:

P1 — useAutoSync.ts: startup retry exhaustion wedged auto-sync.
The retry effect previously returned at `attempt >= 4` without
opening `remoteCheckDoneRef`. A session with persistent inspect
failures (long network outage, provider rate-limit loop) left
auto-sync silently disabled for the rest of the session until
restart or provider/unlock transition. After exhaustion, open the
gate: the specific dangers we gate-closed against (empty-push,
partial-apply push) are now covered by independent guards
(`hasMeaningfulSyncData`, the apply-in-progress sentinel, and
`checkProviderConflict`'s inspect-failure throw at upload time).
This matches manual sync's existing semantic rather than silently
strict-gating auto-sync.

P2 — CloudSyncSettings.tsx: restore buttons were per-row disabled,
not globally. A user could click Row A, then Row B while A was
still applying — two concurrent `applyProtectedSyncPayload` calls
in the same window. `withRestoreBarrier` serializes across windows
but NOT same-window re-entry, so the second restore's
sentinel-clear could mask a still-partial first apply. Disable
every restore button while any restore is in flight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: keep auto-sync gate closed on retry exhaust; open on manual sync

Codex's re-review of PR #723 correctly flagged that opening the
auto-sync gate after startup retry exhaustion reintroduces the
destructive-clobber path the gate was supposed to prevent. Concrete
scenario: local vault is partially lost (non-empty, just missing
entries), remote has not changed since our last anchor, user edits a
field after a long outage → auto-sync pushes the partially-lost
vault over the intact remote. `checkProviderConflict` doesn't catch
this (anchor matches), `hasMeaningfulSyncData` doesn't catch this
(non-empty), and the empty-vault prompt doesn't fire.

Revert the retry-exhaust gate-open. The gate now stays closed until
either:

  1. A startup `checkRemoteVersion` succeeds (normal path), OR
  2. A `syncNow` completes successfully. A manual sync from Settings
     implicitly runs per-provider `checkProviderConflict` — the same
     inspect the startup path would have done — so a successful
     manual sync is equivalent to a successful startup reconciliation
     from the gate's point of view and opens the gate for the rest
     of the session.

This preserves Codex's safety ask (no auto-push without a confirmed
remote state) while giving the user a clear escape hatch (manual
sync) that doesn't require a restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:37:36 +08:00
陈大猫
6c8a39d269 feat: add stable CSS hooks to tab components (#714) (#722)
* feat: add stable CSS hooks to tab components (#714)

Expose stable attributes on every tab-like element so custom CSS can
target them reliably without chaining utility-class selectors or
relying on inline-style substring matches:

- data-tab-id: already present on session/workspace/logView/sftp tabs;
  now also added to the side-panel buttons (sftp/scripts/theme/ai)
  in TerminalLayer.tsx.
- data-tab-type: session | workspace | logView | sftp | sidepanel,
  lets a selector target one tab family without matching the rest.
- data-state: active | inactive, mirroring Radix Tabs' convention so
  users who already style Settings tabs can reuse the same idiom.
- .netcatty-tab class: a single, scope-free hook for "every tab,
  anywhere" — pairs with data-state="active" for the common "style
  the selected tab" recipe.

No visual changes. The existing inline-style / utility-class selectors
the issue reporter had to chain ([style*="var(--top-tabs-active-bg"],
.app-no-drag.relative.h-7.px-3, etc.) keep working, so no breakage
for people who've already written custom CSS.

Custom CSS can now be written as:

  .netcatty-tab[data-state="active"] { ... }
  [data-tab-type="sftp"][data-state="active"] { ... }
  [data-tab-id="ai"][data-state="active"] { ... }

Closes #714

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add CSS hooks to the root Vaults/SFTP tabs (#714)

The fixed-left root tabs ("Vaults" and "SFTP") in TopTabs.tsx were
missed in the first pass — they don't go through the session /
workspace / logView branches, so their div rendered without the new
data-tab-id / data-tab-type / data-state attributes or the
.netcatty-tab class.

Add them so custom CSS can target the whole root tab row the same
way:

  [data-tab-type="root"][data-state="active"] { ... }
  [data-tab-id="vault"] { ... }
  [data-tab-id="sftp"] { ... }

No visual change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:22:15 +08:00
陈大猫
db69d5ac39 [codex] Harden sync overwrite protection and add local restore history (#720)
* fix: harden sync overwrite recovery

* refactor: separate backup retention settings

* refactor: align backup retention controls

* refactor: simplify backup retention card

* fix: address PR #720 deep-review findings

- Close the cross-window restore race by holding a time-bounded barrier
  in localStorage during every destructive apply; useAutoSync skips
  pushes while it's set, preventing a pre-restore snapshot from
  clobbering just-restored cloud data.
- Round-trip startup three-way merges so merged-in local additions
  actually reach the cloud instead of living only on the device that
  ran the merge until the next edit.
- Upgrade sync signatures from a 64-char ciphertext prefix to full
  SHA-256 (v3), closing the tail-mutation replay weakness.
- Harden the vault-backup IPC: payload size cap, enum-validated reason,
  sanitized version strings, strict maxCount, concurrent-call mutex,
  monotonic createdAt to avoid same-ms ordering ties.
- Extract the anchor-change decision into a pure module with unit tests
  covering no-anchor, resource-id drift, and signature mismatch paths.
- Capture the protective backup from the pre-apply closure snapshot so
  it reflects what's being replaced rather than what was imported.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR #720 follow-up review findings

Make protective backup abort-on-failure (was best-effort console.error),
preserve nested syncedAt in fingerprint, use UTF-8 byte length for size
guard, throw on conflict-inspect failure so stale uploads can't leak
through, treat unreadable remote as changed, canonical-JSON signature
meta, and hold the version stamp on transient backup failures so the
retry path still fires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address second-pass review findings on PR #720

- Hold version-change stamp when payload is non-meaningful (covers the
  startup vault-rehydrate race where a transient empty snapshot would
  permanently skip the upgrade backup).
- readBackupRecord stat-checks before readFile so an oversized file in
  the backup dir cannot OOM the renderer on enumeration.
- Reject maxBackups input outside 1..100 instead of silently clamping
  (matches the i18n error copy and the main-process sanitizer bound).
- Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a
  concurrent auto-sync in another window cannot interleave.
- sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto
  subtle; createSyncedFileSignature returns null, forcing the
  unreadable-remote → three-way-merge path instead of a weak
  length-only pseudo-signature.
- Document that array order in normalizePayloadForHash is an invariant
  enforced by producers, not the hash function.
- Drop three-way-merge completion logs from console.log to console.info.
- Comment the implicit restore → store-listener refresh chain so
  future refactors don't silently break the UI reload path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address third-pass review findings on PR #720

Resolves I-3 through I-8 and related cleanup items identified in the
deep review. Highlights:

- replace setTimeout(0) post-merge round-trip with a direct
  syncAllProviders call using the already-computed merged payload,
  removing the React-commit race
- resolve the empty-vault confirmation promise on unmount so a
  mid-dialog window teardown doesn't leak the resolver
- retry the version-change backup as hosts/keys hydrate, instead of
  latching on the first (possibly empty) snapshot
- heartbeat-refresh the cross-window restore barrier so long applies
  cannot expose a post-60s window to concurrent auto-sync
- add a diagnostic warning when connected providers hold divergent
  bases (multi-account configurations)
- surface a user-visible "Sync paused" toast when startup inspect
  fails, replacing the previous silent gate-open
- tie-break backup list sort by id when createdAt collides
- extract applyProtectedSyncPayload so the main and settings windows
  cannot drift on restore-barrier / protective-backup handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address deep-review findings on PR #720

Deep re-review surfaced six Important issues that survived the prior
four review rounds. All are hardened here:

- I1: fsync the protective backup file AND its directory before the
  rename completes, so a system crash between backup creation and the
  restore it guards cannot leave a torn/zero-length safety net.
- I3: persist an apply-in-progress sentinel across the non-atomic
  localStorage writes in applySyncPayload. A crash mid-apply now
  surfaces on the next startup (toast + refuse auto-push) instead of
  silently pushing the half-applied state over an intact cloud copy.
- I2: only open the auto-sync gate (remoteCheckDoneRef) when the
  startup inspect validated cleanly. Add a bounded exponential-backoff
  retry so a transient inspect failure self-heals instead of wedging
  auto-sync until restart.
- I5: save the sync base BEFORE advancing the per-provider anchor
  inside uploadToProvider. A renderer crash between the two writes
  now degrades to "stale anchor forces re-inspect on next run," which
  re-merges against the fresh base — eliminating the silent
  base-drift window where a 3rd-device race could misclassify
  entries.
- I6: main process broadcasts a vaultBackups:changed IPC event on
  every mutation; useLocalVaultBackups subscribes so protective
  backups created from the main window show up in the Settings
  backup list without manual refresh.
- I4: update PR description + code comment to match the actual
  (safer) design: auto-sync gate opens on vault init, with
  hasMeaningfulSyncData + restore barrier preventing empty-push; the
  version-change backup is best-effort and retries as data hydrates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: serialize startup checkRemoteVersion and stabilize its deps

Re-review flagged that checkRemoteVersion's useCallback depended on
`config` — a fresh object literal from App.tsx on every render — so
the retry effect restarted with attempt=0 on every vault edit and
could spawn overlapping in-flight inspect+apply runs. Two concurrent
commitRemoteInspection + onApplyPayload calls could race on the
apply-in-progress sentinel around interleaved writes.

Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady`
through refs so checkRemoteVersion's identity no longer churns with
unrelated App state. Add an in-flight guard that returns early when a
previous invocation is still awaiting the network, closing the
same-window re-entry gap that withRestoreBarrier intentionally doesn't
cover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: release in-flight lock on no-connected-provider early return

Third-pass review caught that `checkRemoteInFlightRef` was acquired
before the `!connectedProvider` check, so that early return leaked
the lock and every subsequent retry-timer tick silently no-op'd.
Move the acquisition past the early return so the only path that
takes the lock reaches the finally-release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:09:55 +08:00
陈大猫
ee400f424b Merge pull request #718 from binaricat/fix/mac-fullscreen-tray-hide-show-race
fix: stop cancelling mac fullscreen tray-hide on internal show event
2026-04-14 23:32:10 +08:00
bincxz
ba93e2fa35 fix: do not cancel pending close-to-tray hide on window show event
Follow-up to the trailing-show fix. Codex review on #718 flagged that
`focusMainWindow()` in main.cjs (called from `app.on("second-instance")`
and as the fallback path of `app.on("activate")`) still calls
`win.show()/focus()` without cancelling any in-flight close-to-tray
pending hide. A user who closes a fullscreen window to tray and then
relaunches the app via a second instance would see the window briefly
reappear and get hidden again when `leave-full-screen` lands.

Add `clearPendingFullscreenHide(win)` at the top of `focusMainWindow()`
so every reopen entry point (dock click, second-instance, activate
fallback) cancels the pending hide before showing the window.
2026-04-14 23:26:38 +08:00
bincxz
591b240d12 fix: wait for trailing show after leave-full-screen before hiding to tray
The previous fix (dropping the show cancellation listener) still left
close-to-tray on a fullscreen mac window with a window-pops-back bug.
Reproduced with main-process logging on macOS 26:

  T+0ms   handleWindowClose + setFullScreen(false) + pending armed
  T+56ms  win.hide (internal, from setFullScreen false)
  T+106ms our polling hid the window (isFullScreen() returned false)
  T+591ms leave-full-screen arrives (animation actually done)
  T+603ms win.show (macOS trailing event, finalizing space transition)

Two realisations:
 1. isFullScreen() flips to false BEFORE the animation is visually
    complete. Polling it and calling win.hide() at that moment caused
    the pop-back (macOS undoes the hide when the animation finishes).
 2. Even without (1), macOS emits a trailing `show` event ~12ms after
    leave-full-screen. Any prior hide gets reversed by that show.

New strategy in hideWindowRespectingMacFullscreen:

  - Do not hide from the polling timer; use polling only as a watchdog
    that gives up after 5s without leave-full-screen (forces the leave
    path anyway so at least the tray-hide is attempted).
  - On leave-full-screen, arm a `once("show")` listener plus a 300ms
    fallback timer. Whichever fires first runs the hide. This way the
    hide lands on top of macOS's trailing show, so the show cannot
    undo it.
  - clearPendingFullscreenHide teardown now covers the new timer and
    the trailing-show listener, so every cancel entry point stays
    correct.

Tests rewritten to match the new state machine (no more poll-based
hide): one for the happy path, one for the trailing-show fallback,
one for the watchdog. All 11 tests pass.
2026-04-14 22:51:21 +08:00
bincxz
880812f48d fix: do not cancel pending close-to-tray hide on window show event
macOS emits a `show` event on the BrowserWindow internally while the
native fullscreen exit animation lands the window back in its home
Space. PR #717's defensive `show` listener in
hideWindowRespectingMacFullscreen treated that as user intent and
cleared the pending hide, so clicking the red close button on a
fullscreen window left it visible on screen instead of going to the
tray.

Remove the `show` listener entirely. The other paths that legitimately
"bring the window back" during the exit animation (openMainWindow,
toggleWindowVisibility, setCloseToTray(false), the tray "Open Main
Window" menu) already call clearPendingFullscreenHide explicitly, so
the listener was only ever catching the internal transition emit.

Also wire app.on("activate") in main.cjs to call
clearPendingFullscreenHide so a dock-click during the exit animation
correctly cancels the pending hide as user intent.

Update the existing regression test to assert the new behavior
(`show` does not cancel; leave-full-screen still does), and add a
new test covering the app-activate path.
2026-04-14 19:04:04 +08:00
陈大猫
445ce92dbc Merge pull request #717 from binaricat/codex/fix-mac-fullscreen-close
[codex] Fix mac fullscreen close-to-tray behavior
2026-04-14 18:00:24 +08:00
bincxz
7f582bb355 tighten fullscreen tray close handling 2026-04-14 17:53:23 +08:00
bincxz
59f9a1443b fix mac fullscreen close-to-tray flow 2026-04-14 17:25:40 +08:00
陈大猫
bcb56d8229 Merge pull request #715 from binaricat/feat/paste-selection-shortcut
feat: add paste-selection terminal command (closes #637)
2026-04-14 16:30:12 +08:00
bincxz
1ca2cd8ec2 feat: add "paste selection" terminal command with bindable shortcut
Adds a new terminal action that pastes the terminal's current selection
at the cursor without going through the system clipboard — the equivalent
of X11 PRIMARY-selection paste. Default shortcut: ⌘ + Shift + X / Ctrl + Shift + X.

Also surfaces the action in the terminal right-click menu, disabled when
there is no selection. Does not change middle-click paste behavior.

Closes #637
2026-04-14 16:22:51 +08:00
陈大猫
717d8b718a Merge pull request #712 from tces1/dev
feat: scope AI draft and session resume state
2026-04-14 15:58:32 +08:00
Eric Chan
363f03a92d fix ai draft scope state updates 2026-04-14 14:57:45 +08:00
Eric Chan
c5d15a14c9 fix: avoid orphaned AI session storage churn
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 12:33:22 +08:00
Eric Chan
75dc3dd72b feat: scope AI draft and session resume state
- persist drafts, panel views, and active sessions per terminal/workspace scope
- restore scoped AI session selection on reconnect and cold mount
- prefer unsent drafts over implicit history fallback
- avoid redundant active session map rewrites during scoped cleanup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 11:55:34 +08:00
陈大猫
110e050d20 Merge pull request #708 from binaricat/feat/claude-agent-dynamic-model-probe
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: dynamically probe claude-agent-acp for available models
2026-04-13 19:55:13 +08:00
bincxz
ebcfe49ed6 fix: clear stale model cache when ACP probe returns empty
Address Codex review feedback on #708: the previous guard silently
returned on an empty-but-ok probe response, which left any previously
cached runtimeAgentModelPresets[currentAgentId] in place. That kept
Claude/Copilot pickers showing stale model IDs (and skipped currentModelId
reconciliation) instead of falling back to the hardcoded presets when the
backend no longer advertised a catalog.

Now we explicitly drop the cache entry so the agentModelPresets memo falls
through to getAgentModelPresets(...) via the `?? ` branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:46:39 +08:00
bincxz
bc8ac08b9a feat: probe claude-agent-acp for available models instead of hardcoded presets
Claude agents now advertise their real model catalog via the ACP
initSession response, just like Copilot already does. Confirmed locally
that `claude-agent-acp` returns `models.availableModels` with full ids +
names + descriptions (default / sonnet / haiku on subscription; and would
return Bedrock/Vertex/custom-proxy ids when the user has configured those).

This closes the gap where the Claude picker was stuck on three hardcoded
entries from CLAUDE_MODEL_PRESETS regardless of what the underlying CLI
actually supports. If the probe fails or returns an empty list, we keep
the hardcoded presets as a fallback.

Codex keeps its existing path via `aiCodexGetIntegration` (reads
~/.codex/config.toml) — we deliberately do not probe codex-acp, since
probing would just return the stock OpenAI model list even when the
user has a custom model_provider set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:37:19 +08:00
陈大猫
309fbdbe7a Merge pull request #707 from binaricat/fix/claude-agent-independent-from-custom-provider
fix: decouple Claude agent auth from netcatty provider list
2026-04-13 19:28:24 +08:00
bincxz
11f831d820 fix: decouple Claude agent auth from netcatty provider list
Apply the same fix as #706 to the Claude Code agent. The `claude` CLI has
its own auth surface (`claude auth login/logout/status`) that manages
subscription-based logins (Claude Max / Pro via claude.ai) alongside
ANTHROPIC_API_KEY / settings-based configs. Silently forwarding a
netcatty-configured provider's API key to claude-agent-acp overrides that
login — the user's subscription gets bypassed and charges go to their API
balance without their knowledge.

Claude's settings card never surfaced the `claude auth status` so this
regression was more hidden than the Codex one, but the underlying coupling
is the same class of bug.

Changes:
- Stop forwarding any providerId for managed ACP agents from the renderer;
  claude-agent-acp now resolves auth purely from its own CLI config / login
  state / shell env.
- Remove ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL injection at all three
  codex-acp / claude-acp spawn sites in aiBridge.
- Drop Claude from the authFingerprint computation (it no longer has any
  netcatty-side input to hash).
- Delete the now-unused `findManagedAgentProvider` helper and its
  ProviderConfig import from managedAgents.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:22:58 +08:00
陈大猫
806fb6cf29 Merge pull request #706 from binaricat/fix/issue-705-codex-independent-from-custom-provider
fix: decouple Codex agent auth from netcatty provider list (#705)
2026-04-13 19:14:08 +08:00
bincxz
cc2702b825 fix: decouple Codex agent auth from netcatty provider list (#705)
Codex agent auth must be determined entirely by ~/.codex/auth.json or
~/.codex/config.toml. Before this change, if the user configured any
OpenAI-compatible API provider in netcatty settings (for Catty agent use),
useAIChatStreaming would silently hand that provider's apiKey to the Codex
agent too, causing aiBridge to spawn codex-acp with authMethodId
"codex-api-key" and completely override the user's ChatGPT login.

The regression was introduced in PR #702 (v1.0.89) when findManagedAgent
Provider started matching generic "custom" providers for Codex. Users who
logged into Codex via ChatGPT and also had a netcatty-configured custom
provider saw the UI flip to "API mode" on refresh and their ChatGPT
session get ignored.

Remove the codex branch from the agentProviderId resolver and from
findManagedAgentProvider itself. Also drop the now-meaningless
hasCompatibleProvider hint on the Codex settings card and its i18n copy.
Claude agent behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:08:18 +08:00
陈大猫
af2589e60b Merge pull request #704 from tces1/MoreSkills
feat: add Netcatty user skills scanning and chat selection flow
2026-04-13 13:33:12 +08:00
Eric Chan
971c8a4d8b fix: harden user skills prompt injection 2026-04-13 12:49:53 +08:00
Eric Chan
59364e0c75 fix: preserve user skill selections on refresh errors 2026-04-13 12:39:33 +08:00
Eric Chan
ac83c4c27d fix: keep user skills state in sync 2026-04-13 11:15:32 +08:00
Eric Chan
aa10f962ea fix: harden user skills scanning 2026-04-13 11:08:09 +08:00
Eric Chan
1f3e531d7b Fix AI skill selection handling 2026-04-13 11:03:43 +08:00
陈大猫
ca6ca3f477 Merge pull request #702 from binaricat/codex/issue-677-codex-provider-followup
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
[codex] finish Codex provider follow-up for #677
2026-04-13 02:34:25 +08:00
bincxz
1c9c4fcec3 fix: address second-round review feedback
- Extract fail-loud check to shared getCodexCustomConfigPreflightError so
  the list-models handler (aiBridge.cjs:2149) enforces the same up-front
  error as the stream handler. Previously a user whose config.toml
  env_key was unexported would get the targeted message on chat send but
  a generic "Missing env var" from model-list probes (once the probe was
  rewired for Codex in a future change).

- Wire Settings "Refresh Status" to also invalidate the shell-env cache.
  New invalidateShellEnvCache() helper in shellUtils; aiCodexGetIntegration
  now accepts an optional { refreshShellEnv } flag; the button passes it
  so a user who just exported OPENROUTER_API_KEY in their rc file can
  click Refresh instead of having to restart netcatty.

- Declare authHash in CodexCustomProviderConfig (types.ts + global.d.ts)
  so renderer TS actually sees the field instead of needing a cast.

- DRY the 360 magic number in ChatInput: extract
  MODEL_PICKER_MAX_WIDTH, use it in both the className max-width and the
  left-clamp math so the two can't drift.

- Move codexCustomConfigResolved useState declaration next to its
  companion codexConfigModel, above the effect that invokes its setter,
  and drop the duplicate declaration further down. Pure code-organization
  cleanup but removes a use-before-declaration nit.

No functional changes beyond the fail-loud parity and the refresh-shell-env
path. ACP behavior when authMethodId is omitted still requires a
real-world OpenRouter config.toml validation, which the user is running.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:30:29 +08:00
bincxz
8f68e24057 fix: address review feedback on config.toml detection flow
Round of fixes driven by two parallel reviewers:

- i18n placeholder mismatch (P0). Locale strings used ${envKey} (literal
  dollar-sign) but the replace call passed '{envKey}', so the warning
  displayed a raw "${envKey}" instead of the real env var name. Align on
  the codebase-standard {envKey} form.

- Fingerprint now folds the hash of the actual auth material (P1).
  readCodexCustomProviderConfig computes a sha256 over the hardcoded
  api_key or the resolved env_key value and returns authHash. The ACP
  provider-reuse fingerprint includes it, so rotating the key in
  ~/.zshrc + restarting netcatty (which refreshes shellEnv) now
  invalidates the cached provider instance instead of keeping the stale
  key alive. Raw value never crosses the IPC boundary — we only send
  the hex digest.

- Fail loud when config.toml's env_key isn't exported (P1). Previously
  we'd sail into spawn and let codex-acp fail mid-request with a cryptic
  "Missing environment variable". Now the stream handler rejects up
  front with a targeted error naming the missing variable and pointing
  at ~/.zshrc.

- TOML parser: basic-string escape tracking (P1). findUnquotedHash now
  tracks an explicit `escaped` flag (and only honors escapes inside
  double-quoted strings, since literal single-quoted strings don't), so
  values like "C:\\path\\" close correctly instead of consuming the
  trailing `#` as part of the string.

- TOML parser: strip UTF-8 BOM (P2). Windows editors frequently prepend
  one and the first-key regex would silently fail to match, dropping
  everything before the first section header.

- Picker correctness when config.toml lacks a `model` field (P1).
  Instead of silently falling back to CODEX_MODEL_PRESETS (stock
  OpenAI IDs the user's custom endpoint can't serve), show an empty
  list so the picker disables. Track codexCustomConfigResolved so we
  distinguish "still loading" from "not a custom-config session" and
  only clear the preset list once the integration probe confirmed
  connected_custom_config.

- Logout handler isConnected also considers connected_custom_config
  (P2 consistency), matching get-integration.

- Model picker popover clamps its left position so max-w-[360px] can't
  push it past the right edge of a narrow AI side panel (P2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:21:36 +08:00
bincxz
2374f67ffc fix: skip ChatGPT auth validation when config.toml provides custom provider
On stream start, aiBridge ran validateCodexChatGptAuth() for any Codex
request without a netcatty-managed API key. That helper spawns a fresh
codex-acp with authMethodId:"chatgpt" and expects the ChatGPT auth.json
to be valid — which it never is for users who only have a custom
model_provider set up in ~/.codex/config.toml. The validation failed,
the main window got "Codex ChatGPT login is stale or invalid. Reconnect
Codex in Settings" over the error channel, and the UI flipped to the
login prompt — exactly the flow the config.toml path is meant to skip.

Move readCodexCustomProviderConfig up so we compute it before the
validation gate, and only run the ChatGPT validation when there's
neither a netcatty-managed API key nor a detected config.toml custom
provider. The rest of the spawn path already omits authMethodId for
the custom-config case, so codex-acp connects directly with the shell
env and config.toml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:13:21 +08:00
bincxz
fea8e8b305 fix: stop probing codex-acp for models; show config.toml model when custom
Two issues the user flagged with the previous round:

1. Probing codex-acp for available models returned the stock ChatGPT
   catalog (GPT 5.4, Codex 5.x, o3, o4-mini) regardless of the active
   provider. For a user with a custom model_provider in
   ~/.codex/config.toml (OpenRouter + Qwen), those IDs are meaningless
   on their endpoint. Roll back the managed-Codex probe hook and go
   back to static CODEX_MODEL_PRESETS for the stock / ChatGPT path.

2. The fixed w-[300px] popover left empty space on the right whenever
   the longest row was narrower than 300px.

Instead of the probe, teach readCodexCustomProviderConfig to also
return the top-level `model` from config.toml and expose it on the
integration response. In AIChatSidePanel, call aiCodexGetIntegration
when Codex is the active agent and, if customConfig.model is present,
override agentModelPresets with a single-entry list pinned to that
model. Otherwise fall back to the static presets as before — so
ChatGPT users see GPT 5.x / Codex 5.x etc. exactly like before, while
custom-config users see just the model their provider is actually
pinned to.

Popover switches from fixed width to `w-max min-w-[160px] max-w-[360px]`
so it hugs content (great for short single-model lists) while still
capping very long rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:10:35 +08:00
bincxz
79a7e460be fix: parse model ids that contain '/' correctly in ChatInput
The picker label was being derived by splitting selectedModelId on the
first '/'. That works for Codex's ChatGPT-preset format
("gpt-5.4/high" → model "gpt-5.4" + thinking level "high"), but breaks
for OpenRouter-style ids from config.toml ("qwen/qwen3.6-plus"):
selectedBaseModelId became "qwen", which doesn't match any preset, so
selectedPreset fell back to undefined and the chip displayed the
unrelated app-level modelName (e.g. "gemini-3-flash-preview") instead
of the actually selected Codex model.

Replace the naive split with a two-step lookup: first try a direct id
match; only if that fails, look for a preset whose declared
thinkingLevels make "${preset.id}/${level}" equal to selectedModelId,
and derive the thinking segment from that. Model ids that happen to
contain '/' now round-trip correctly through the picker.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:07:05 +08:00
bincxz
f48db8ee4e fix: drop description from model picker to keep it compact
codex-acp's provider descriptions can be paragraphs ("Latest frontier
model with improvements across a wide range of capabilities..."), which
made each row of the picker feel bloated. The model id and (thinking
sub-menu's) thinking level already convey the relevant distinction —
drop the description render entirely. Keeps the dropdown tight regardless
of how verbose the upstream model catalog is.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:57 +08:00
bincxz
ba2a0389fa fix: stack model picker description below name (vertical layout)
Horizontal layout + truncate clipped too much of codex-acp's longer
descriptions ("Latest frontier model with improvements across a..." →
"Latest frontier model w..."). Reorganize each option as
checkmark | name-on-top, wrapped description below | chevron, so the
full description is readable across two lines without pushing the
popover width out. Fix popover to w-[300px] for a consistent column
width. Checkmark and chevron anchor to the first text line (self-start
with small top offset) so they stay visually aligned with the name
when the description wraps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:03:24 +08:00
bincxz
6309a49c37 fix: cap model picker width and truncate long descriptions
With dynamic models now pulled from codex-acp, preset descriptions can be
arbitrarily long ("Latest frontier model with improvements across a..."
from OpenAI's public model list). The popover had whitespace-nowrap on
each option and no max-w on the container, so long descriptions pushed
the dropdown off-screen.

Cap the popover at max-w-[360px], add min-w-0 + truncate to the name
span so flex children can actually shrink, and cap the description span
at max-w-[160px] with truncate so it ellipses rather than expanding the
row. ChevronRight gets shrink-0 so it can't be pushed out of view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:02:02 +08:00
bincxz
b1291d3ee2 fix: probe codex-acp for available models instead of using hardcoded preset
AIChatSidePanel gates dynamic model probing behind isCopilotExternalAgent,
so Codex always fell back to CODEX_MODEL_PRESETS — a hardcoded list of
OpenAI-specific IDs (GPT 5.4, Codex 5.x, o3, o4-mini). That's only correct
for the stock ChatGPT/OpenAI path. When the user has a custom
model_provider in ~/.codex/config.toml (OpenRouter, local inference, etc.),
none of those IDs exist on their endpoint and the model picker is useless.

Extend the condition to also trigger the aiAcpListModels probe for the
Codex managed agent (detected via matchesManagedAgentConfig). The probe
launches codex-acp the same way a real session does, so it now also goes
through getCodexAuthOverride and respects the user's config.toml — and
whatever availableModels codex-acp returns (typically at least the
`model` field from config.toml) shows up in the picker. Claude keeps its
curated presets to avoid regressing that path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:00:04 +08:00
bincxz
18c001e9c5 fix: show custom config even when env_key is not exported yet
The first pass required both a custom model_provider in ~/.codex/config.toml
AND the referenced env_key to already be present in the shell environment.
If a user had the config file set up but hadn't (yet) exported the key in
their shell, detection returned null and the UI fell back to "Not
connected" + "Connect ChatGPT" — which is the exact flow they were trying
to avoid.

The config.toml is a strong enough signal of intent on its own. Keep the
integration in the connected_custom_config state regardless of env_key
availability, but expose envKeyPresent on the response so the UI can
explicitly warn "Warning: $MY_KEY is not set in your shell — export it".
Status label and color also flip to amber ("Custom config detected — env
var missing") so the state is easy to spot without dropping back to the
login prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:53:33 +08:00
bincxz
c2c6b265d4 feat: detect user's ~/.codex/config.toml custom provider as ready state
Users who hand-configure ~/.codex/config.toml with a custom model_provider
and matching [model_providers.<name>] entry are fully functional from the
Codex CLI, but netcatty only looked at codex login status — which reports
on ~/.codex/auth.json alone — and would therefore push them into the
ChatGPT login flow even though the CLI works for them.

Add a minimal TOML parser for the narrow subset we need (top-level keys
plus [model_providers.<name>] string tables), and readCodexCustomProvider
Config() to detect a usable custom-provider setup: an active model_provider
that isn't the built-in openai preset, pointing at a provider entry whose
env_key is set in the shell env (or api_key is hardcoded).

Surface this as a new integration state "connected_custom_config", add a
customConfig summary on the IPC response, and tweak the Codex settings
card so it shows the custom-provider name, hides the Connect ChatGPT
button, and drops the stale "OpenAI-compatible provider" hint when this
path is active.

At Codex-ACP spawn time, introduce getCodexAuthOverride() so we only pass
authMethodId: "chatgpt" when we truly have no other option. When a
netcatty-managed API key is present we still use "codex-api-key"; when the
user has a custom config we omit authMethodId entirely so codex-acp
resolves auth from the shell env / config.toml itself. Fold the detected
custom config (provider name, base url, env key presence) into the
provider reuse fingerprint so edits to config.toml invalidate cached ACP
instances.

Fixes the Codex half of #677 for users who skip Settings → AI providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:49:23 +08:00
bincxz
1e50b66407 fix: finish Codex provider follow-up for #677 2026-04-13 01:21:05 +08:00
陈大猫
2fb2155d79 Merge pull request #701 from binaricat/feat/issue-695-preserve-buffer-on-reconnect
feat: preserve terminal buffer across reconnect (#695)
2026-04-13 01:12:01 +08:00
bincxz
3429c498f9 fix: cancel pending retry when session is closed or cancelled
Per Codex P1 on #701: the nested term.write callbacks in handleRetry
kept a captured reference to startNewSession. If the user hit Cancel or
closed the tab while those writes were still queued, cleanupSession ran
first but the callback could still fire afterwards — opening a backend
session with no owning UI (a ghost connection that nothing would tear
down).

Introduce retryTokenRef. handleRetry stamps a fresh Symbol, captures it,
and the chained callbacks verify the token (plus termRef identity) is
still current before proceeding. Invalidate the token from every path
that ends the retry intent: handleCancelConnect, handleCloseDisconnected
Session, teardown. A subsequent handleRetry naturally invalidates the
prior one by overwriting the ref, so rapid double-clicks are also safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:06:08 +08:00
bincxz
dc7b14e323 fix: delay new session start until reset sequence has flushed
Per Codex P1 on #701: term.write is asynchronous, but handleRetry was
calling sessionStarters.start* synchronously right after scheduling
the soft-reset write. On fast reconnect paths (local and serial
especially, where the backend has no network round-trip), the new
session's first output bytes can reach xterm before the \x1b[!p...\x1b[H
reset has been applied. That means the reset/home runs mid-stream of
the first prompt, repositioning the cursor or flipping modes partway
through the shell's init and producing intermittent corrupted first
screens.

Extract the protocol dispatch into startNewSession and pass it as the
callback of the second term.write, so the new session only starts
once every preparation byte (alt-screen exit, viewport preserve,
DECSTR, xterm mode disables, cursor home) has actually been applied
to the terminal state. State updates that only drive the UI overlay
(status, progress logs) stay synchronous so users see "connecting..."
immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:22 +08:00
bincxz
5d675b9cef fix: exit alt-screen before preserving viewport; use DECSTR for mode reset
Addresses two Codex findings on #701:

P1 (alt-screen ordering) — preserveTerminalViewportInScrollback only
operates on the normal buffer. If the user disconnected while inside
vim/less/top, the alt buffer was active, preserve was a no-op, and
when \x1b[?1049l later switched back to normal, the new session wrote
over still-visible pre-disconnect content instead of a cleared
viewport. Send \x1b[?1049l first, then wait for the write to flush
(via xterm's write callback) before calling preserve, so it always
runs on the normal buffer.

P2 (DECCKM / keypad / other VT220 modes) — the previous reset sequence
only disabled xterm extensions (mouse tracking, bracketed paste) and
touched SGR / cursor visibility. Full-screen apps commonly enable
DECCKM (application cursor keys) and keypad application mode; those
would leak into the new session and break arrow-key history
navigation and numeric keypad input. Use DECSTR (\x1b[!p) — soft
terminal reset — to reset DECCKM, keypad mode, SGR, insert/replace,
origin mode, and cursor visibility in one shot without clearing the
buffer. Keep explicit disables for the xterm-specific modes DECSTR
doesn't cover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:42:36 +08:00
bincxz
bf9f0e1fc2 fix: reset bracketed-paste mode on reconnect
Per Codex P2 on #701: handleRetry previously removed term.reset() but
the replacement escape sequence didn't disable bracketed paste (DECSET
2004). If the disconnected session had turned it on, term.modes
.bracketedPasteMode stayed true into the next connection; the paste
and snippet paths in createXTermRuntime keep wrapping input with
\x1b[200~ ... \x1b[201~ markers. When the new session hasn't itself
enabled bracketed paste, the shell echoes those markers as literal
text and mangles pastes.

Add \x1b[?2004l to the retry reset sequence so bracketed-paste state
starts off for the new session; the new shell's init will re-enable
it normally if it wants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:34:55 +08:00
bincxz
02967d9258 fix: do not clear terminal buffer at the top of session starters
Each session starter (startSSH / startTelnet / startMosh / startLocal)
called term.clear() as its first step. In xterm.js, clear() wipes the
entire buffer including scrollback. On initial connect this is harmless
(the buffer is already empty), but on retry it undoes the viewport
preservation that handleRetry just performed — so #695 remained broken
for any protocol that went through these starters (i.e. all of them).

The clear call served no purpose: xterm mounts with an empty buffer and
nothing writes to it before the starter runs. Remove the four
try/catch(term.clear()) blocks so handleRetry's
preserveTerminalViewportInScrollback actually sticks across reconnect
on SSH reboots, telnet drops, mosh/local respawns, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:33:16 +08:00
bincxz
343176120e feat: preserve terminal buffer across reconnect (#695)
On disconnect + retry, handleRetry previously called term.reset(), which
wipes both the visible screen and the scrollback history — so users lost
every bit of context from the previous session the moment they hit
"Start Over".

Push the current viewport into scrollback via the existing
preserveTerminalViewportInScrollback utility, then explicitly disable
the modes we actually care about not leaking across sessions (mouse
tracking 1000/1002/1003/1006, alt-screen 1049, SGR attributes, hidden
cursor) and home the cursor. This keeps the full scrollback intact so
users can scroll up to read everything from before the disconnect,
while still preventing stale escape-sequence state from bleeding into
the new session.

Fixes #695

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:25:56 +08:00
陈大猫
c0b4dace87 Merge pull request #700 from binaricat/feat/issue-690-sftp-tab-toggle
feat: add setting to hide the standalone SFTP top tab (#690)
2026-04-13 00:21:20 +08:00
bincxz
b6e8d63fef fix: remove SFTP from QuickSwitcher when SFTP tab is hidden
Per Codex P2 review on #700: QuickSwitcher always listed an 'sftp' tab
item, but with showSftpTab off the App-level redirect bounces the user
straight back to Vault. That left a dead entry in quick-switch — selecting
it appeared broken.

Thread showSftpTab through QuickSwitcher and skip the SFTP item in both
the flat item list (used for keyboard selection indexing) and the
rendered built-in Tabs row when the top tab is hidden. Keeps every
SFTP navigation surface consistent with the visibility setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:13:31 +08:00
bincxz
60c07da140 fix: exclude hidden SFTP tab from keyboard tab cycling
Per Codex P1 review on #700: when showSftpTab is off, executeHotkeyAction
still built allTabs as ['vault', 'sftp', ...orderedTabs]. nextTab from
Vault would land on hidden 'sftp', the showSftpTab effect then redirected
back to 'vault', trapping tab cycling so Ctrl/Cmd+Tab could not advance
into terminal tabs. Number shortcuts (Ctrl+1..9) were also shifted, e.g.
tab 2 resolved to hidden SFTP and ping-ponged back to Vault.

Build allTabs conditionally so 'sftp' is only in the cycle when the tab
is visible. This keeps nextTab/prevTab/switchToTab consistent with what
the user sees in the top tab bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:05:36 +08:00
bincxz
f89afc0e05 feat: add setting to hide the standalone SFTP top tab
Adds a "Show SFTP tab" toggle in Settings → Appearance (under the
Vault section) that controls visibility of the standalone SFTP view
in the top tab bar. When disabled:

- The SFTP tab is removed from the top tab strip.
- The openSftp hotkey (Ctrl+Shift+O / ⌘⇧O) becomes a no-op.
- If the user is currently on the SFTP tab, the active tab auto-
  switches to Vaults.

The in-session SFTP side panel (opened from the terminal toolbar) is
unaffected — that is the surface users keep when they hide the
top-level tab.

Setting persists via localStorage, syncs across windows, and is
included in the cloud SyncPayload alongside the existing Vault
visibility toggles (showRecentHosts,
showOnlyUngroupedHostsInRoot). Default: on.

Addresses the first ask in #690.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:57:15 +08:00
陈大猫
ca0b1ed9ae Merge pull request #699 from binaricat/fix/issue-694-ctrl-f-hardcoded
fix: remove hardcoded Ctrl+F handler bypassing configurable shortcuts
2026-04-12 23:46:09 +08:00
bincxz
555438a02a fix: set Ctrl+F as the default PC shortcut for terminal search
Previously the documented default was Ctrl+Shift+F on PC, but a
hardcoded handler always captured plain Ctrl+F regardless of the
configured binding — so the effective default users experienced was
Ctrl+F. Now that the hardcoded handler is removed, align the declared
default with that historical behavior so existing users don't lose the
shortcut they were used to. Users who need plain Ctrl+F for the shell
(e.g. zsh forward-char) can remap or disable it in Settings → Shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:44:05 +08:00
bincxz
97e78624bb fix: remove hardcoded Ctrl+F handler that bypassed configurable shortcuts
The xterm custom key event handler intercepted plain Ctrl+F / Cmd+F to
open terminal search, ignoring the user's configured keybinding scheme.
This conflicted with zsh's forward-char (Ctrl+F) and gave users no way
to disable it via the Shortcuts settings tab.

The configurable keybinding system below already routes the
searchTerminal action via checkAppShortcut, with defaults of
Ctrl+Shift+F (PC) and Cmd+F (Mac). Dropping the hardcoded branch
lets the user's settings take effect. Also remove the stale
"(Ctrl+F)" label from the toolbar tooltip since the shortcut is
configurable and the default on PC is Ctrl+Shift+F.

Fixes #694

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:42:27 +08:00
陈大猫
eab1e8db67 Merge pull request #698 from binaricat/codex/issue-638-root-ungrouped-hosts
[codex] Add vault root ungrouped host filter toggle
2026-04-12 23:36:47 +08:00
bincxz
8e6392e503 persist vault root filter toggles immediately 2026-04-12 23:30:02 +08:00
bincxz
8b99f2411f fix vault root host filter sync and empty states 2026-04-12 23:27:36 +08:00
bincxz
98905b9c81 fix vault hosts section initialization order 2026-04-12 23:14:59 +08:00
bincxz
b7e1df9916 hide empty root hosts section 2026-04-12 23:13:44 +08:00
bincxz
3089cab88d add vault root ungrouped host toggle 2026-04-12 23:09:03 +08:00
Eric Chan
50b20eaa05 chore: triple-pass review and hardening of AI Skills logic 2026-04-12 17:25:45 +08:00
Eric Chan
3ab42bf588 chore: final hardening of User Skills logic and async IO 2026-04-12 17:14:49 +08:00
Eric Chan
84423a0096 fix: resolve TypeScript errors and optimize User Skills with async IO 2026-04-12 17:11:50 +08:00
陈大猫
98dda8a51b Merge pull request #693 from binaricat/fix/claude-acp-custom-model-provider
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: Claude ACP agent now uses custom API key and base URL
2026-04-12 00:51:25 +08:00
bincxz
42baa5cb78 fix: include provider base URL in ACP reuse fingerprint for Claude
The ACP provider reuse gate only computed authFingerprint for Codex,
leaving it null for Claude. Changing the configured provider or base
URL mid-session would keep reusing the stale provider instance.

Now Claude computes an authFingerprint from apiKey + baseURL, so
changing either value invalidates the cached provider and forces
recreation with the new credentials/endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:37:34 +08:00
bincxz
11fd7fcd71 fix: prefer anthropic provider over generic custom for Claude ACP
A generic custom provider (OpenAI-compatible) could be selected for
Claude, passing wrong credentials. Now we prefer an explicit anthropic
provider and only fall back to a custom provider when it has a baseURL
configured (indicating intentional Anthropic-compatible gateway use).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:31:01 +08:00
bincxz
d6950948fa fix: also inject OPENAI_BASE_URL for Codex ACP agent
Codex reads OPENAI_BASE_URL to connect to custom API endpoints.
Without this, users with a custom baseURL on their OpenAI provider
config would still hit the default api.openai.com endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:29:14 +08:00
bincxz
9693793bba fix: allow Claude ACP agent to use custom API key and base URL
The renderer only resolved OpenAI providers (for Codex) when passing
provider IDs to the main process. Claude agent was never matched, so
no API key was injected. Additionally, the main process only injected
CODEX_API_KEY — never ANTHROPIC_API_KEY or ANTHROPIC_BASE_URL.

Changes:
- Renderer now resolves anthropic/custom provider for Claude agent,
  openai provider for Codex agent (via matchesManagedAgentConfig)
- Main process injects ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL into
  claude-agent-acp env when a provider is configured, across all three
  ACP provider creation paths (list-models, stream, fallback)

This enables users who configure an Anthropic provider with a custom
base URL (e.g. CC Switch proxy) to use Claude Code without being
redirected to the official OAuth flow.

Closes #677

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:26:24 +08:00
陈大猫
a72f012851 Merge pull request #692 from binaricat/fix/scrollback-zero-wheel-scroll
fix: mouse wheel scrolling broken when scrollback set to 0
2026-04-12 00:04:44 +08:00
bincxz
1368709f4e fix: map scrollback=0 to large value so mouse wheel scrolling works
xterm.js treats scrollback=0 as "no scrollback buffer", which makes
hasScrollback return false and converts wheel events into arrow-key
sequences. The UI uses 0 to mean "no limit", so map it to 999999
before passing to xterm.js.

Closes #689

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:00:18 +08:00
Eric Chan
58bc08a045 Add user skills injection and picker UI 2026-04-10 20:53:39 +08:00
73 changed files with 9032 additions and 894 deletions

177
App.tsx
View File

@@ -20,12 +20,21 @@ import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { applySyncPayload } from './application/syncPayload';
import type { SyncPayload } from './domain/sync';
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
} from './application/localVaultBackups';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
import {
STORAGE_KEY_DEBUG_HOTKEYS,
STORAGE_KEY_PORT_FORWARDING,
} from './infrastructure/config/storageKeys';
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -222,6 +231,7 @@ function App({ settings }: { settings: SettingsState }) {
}, [workspaceFocusStyle]);
const {
isInitialized: isVaultInitialized,
hosts,
keys,
identities,
@@ -307,6 +317,12 @@ function App({ settings }: { settings: SettingsState }) {
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
useEffect(() => {
if (!settings.showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
@@ -389,6 +405,129 @@ function App({ settings }: { settings: SettingsState }) {
[portForwardingRules],
);
const buildCurrentSyncPayload = useCallback(() => {
let effectivePortForwardingRules = portForwardingRulesForSync;
if (effectivePortForwardingRules.length === 0) {
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePortForwardingRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return buildSyncPayload(
{
hosts,
keys,
identities,
snippets,
customGroups,
snippetPackages,
knownHosts: getEffectiveKnownHosts(knownHosts),
groupConfigs,
},
effectivePortForwardingRules,
);
}, [
customGroups,
groupConfigs,
hosts,
identities,
keys,
knownHosts,
portForwardingRulesForSync,
snippetPackages,
snippets,
]);
const [startupSyncSafetyReady, setStartupSyncSafetyReady] = useState(false);
// buildCurrentSyncPayload's identity changes each time the vault
// settles. The retry effect below watches the underlying data arrays
// for hydration progress, and uses the ref to always read the latest
// builder without pulling buildCurrentSyncPayload itself into deps
// (its identity churns on unrelated state updates too).
const buildCurrentSyncPayloadRef = useRef(buildCurrentSyncPayload);
useEffect(() => {
buildCurrentSyncPayloadRef.current = buildCurrentSyncPayload;
}, [buildCurrentSyncPayload]);
const versionBackupAttemptedRef = useRef(false);
// Two-stage gate: once the vault has initialized we open the auto-sync
// gate immediately — the hook's own hasMeaningfulSyncData guard and
// the cross-window restore barrier prevent an empty-but-not-yet-
// hydrated snapshot from overwriting cloud data. The version-change
// backup itself is best-effort and retries below as vault data arrives.
useEffect(() => {
if (isVaultInitialized && !startupSyncSafetyReady) {
setStartupSyncSafetyReady(true);
}
}, [isVaultInitialized, startupSyncSafetyReady]);
// Retry the version-change backup as hosts/keys/snippets become
// available. ensureVersionChangeBackup refuses to advance the stored
// version stamp when the observed payload is empty, so running this
// effect repeatedly is safe and eventually latches once the vault has
// hydrated enough to be backed up (or the user genuinely stays empty,
// in which case the effect continues to no-op).
useEffect(() => {
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
const payload = buildCurrentSyncPayloadRef.current();
if (!hasMeaningfulSyncData(payload)) return;
versionBackupAttemptedRef.current = true;
let cancelled = false;
void (async () => {
try {
const info = await netcattyBridge.get()?.getAppInfo?.();
await ensureVersionChangeBackup(payload, info?.version ?? null);
} catch (error) {
if (!cancelled) {
// Reset the latch so a later data change (or the next mount)
// can retry. ensureVersionChangeBackup already leaves the
// version stamp untouched on failure, so retrying is safe.
versionBackupAttemptedRef.current = false;
}
console.error('[App] Failed to create version-change backup:', error);
}
})();
return () => {
cancelled = true;
};
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
// on unrelated App-level state changes (which would churn the debounced
// auto-sync useEffect dep chain).
const handleApplySyncPayload = useCallback(
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: () =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
}),
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
}),
[
buildCurrentSyncPayload,
importDataFromString,
importPortForwardingRules,
settings.rehydrateAllFromStorage,
t,
],
);
// Auto-sync hook for cloud sync
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
hosts,
@@ -401,13 +540,8 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
});
},
startupReady: startupSyncSafetyReady,
onApplyPayload: handleApplySyncPayload,
});
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
@@ -553,7 +687,7 @@ function App({ settings }: { settings: SettingsState }) {
if (binding.category === 'sftp') {
continue;
}
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
const terminalActions = ['copy', 'paste', 'pasteSelection', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return;
@@ -893,13 +1027,18 @@ function App({ settings }: { settings: SettingsState }) {
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
// doesn't land on a hidden tab (which would get redirected back) and so
// number shortcuts don't shift.
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs]
: ['vault', ...orderedTabs];
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
if (num <= allTabs.length) {
setActiveTabId(allTabs[num - 1]);
}
@@ -907,8 +1046,6 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'nextTab': {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
@@ -920,8 +1057,6 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'prevTab': {
// Build complete tab list: vault + sftp + sessions/workspaces
const allTabs = ['vault', 'sftp', ...orderedTabs];
const currentId = activeTabStore.getActiveTabId();
const currentIdx = allTabs.indexOf(currentId);
if (currentIdx !== -1 && allTabs.length > 0) {
@@ -968,7 +1103,9 @@ function App({ settings }: { settings: SettingsState }) {
setActiveTabId('vault');
break;
case 'openSftp':
setActiveTabId('sftp');
if (settings.showSftpTab) {
setActiveTabId('sftp');
}
break;
case 'quickSwitch':
case 'commandPalette':
@@ -1056,7 +1193,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -1424,6 +1561,7 @@ function App({ settings }: { settings: SettingsState }) {
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
showSftpTab={settings.showSftpTab}
/>
<div className="flex-1 relative min-h-0">
@@ -1469,6 +1607,8 @@ function App({ settings }: { settings: SettingsState }) {
onClearUnsavedConnectionLogs={clearUnsavedConnectionLogs}
onRunSnippet={runSnippet}
onOpenLogView={openLogView}
showRecentHosts={settings.showRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
/>
@@ -1582,6 +1722,7 @@ function App({ settings }: { settings: SettingsState }) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onSelectTab={(tabId) => {

View File

@@ -202,6 +202,10 @@ const en: Messages = {
'settings.vault.title': 'Vault',
'settings.vault.showRecentHosts': 'Show recently connected hosts',
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
// Update notifications
'update.available.title': 'Update Available',
@@ -439,10 +443,15 @@ const en: Messages = {
'sync.toast.completedMessage': 'Sync completed successfully',
'sync.toast.errorTitle': 'Sync Error',
'sync.autoSync.failedTitle': 'Sync failed',
'sync.autoSync.inspectFailedTitle': 'Sync paused',
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
'sync.autoSync.syncedTitle': 'Synced from cloud',
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
@@ -1152,7 +1161,7 @@ const en: Messages = {
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal (Ctrl+F)',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
@@ -1208,6 +1217,7 @@ const en: Messages = {
'terminal.search.nextMatch': 'Next match (Enter)',
'terminal.menu.copy': 'Copy',
'terminal.menu.paste': 'Paste',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
@@ -1386,6 +1396,31 @@ const en: Messages = {
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
'cloudSync.localBackups.maxCount': 'Max backups',
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
'cloudSync.localBackups.empty': 'No local backups yet.',
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
'cloudSync.localBackups.restore': 'Restore',
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
'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.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.restoreConfirmButton': 'Restore',
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
'cloudSync.localBackups.lockedTitle': 'Master key required',
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
'cloudSync.revisionHistory.viewButton': 'History',
'cloudSync.revisionHistory.title': 'Vault Version History',
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
@@ -1740,12 +1775,16 @@ const en: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
@@ -1756,7 +1795,6 @@ const en: Messages = {
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1789,6 +1827,17 @@ const en: Messages = {
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'User Skills',
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder': 'Open Skills Folder',
'ai.userSkills.reload': 'Reload Skills',
'ai.userSkills.location': 'Location',
'ai.userSkills.loading': 'Scanning user skills...',
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
@@ -1843,6 +1892,7 @@ const en: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
// 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.',

View File

@@ -186,6 +186,10 @@ const zhCN: Messages = {
'settings.vault.title': '主机库',
'settings.vault.showRecentHosts': '显示最近连接的主机',
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
// Update notifications
'update.available.title': '发现新版本',
@@ -258,10 +262,15 @@ const zhCN: Messages = {
'sync.toast.completedMessage': '同步完成',
'sync.toast.errorTitle': '同步错误',
'sync.autoSync.failedTitle': '同步失败',
'sync.autoSync.inspectFailedTitle': '同步已暂停',
'sync.autoSync.inspectFailedMessage': '无法访问云端以检查变更。数据改动或下次启动时会自动重试。',
'sync.autoSync.syncedTitle': '已从云端同步',
'sync.autoSync.syncedMessage': '你的数据已从云端更新。',
'sync.autoSync.noProvider': '未连接云同步 provider。请打开 设置 → Sync & Cloud 进行连接。',
'sync.autoSync.alreadySyncing': '同步正在进行中。',
'sync.autoSync.restoreInProgress': '另一个窗口中的本地备份恢复正在进行中,请等待其完成。',
'sync.autoSync.interruptedApplyTitle': '同步已暂停 — 上次恢复未完成',
'sync.autoSync.interruptedApplyMessage': '上次本地恢复过程未正常结束,本地数据可能处于半应用状态。请打开「设置 → Sync & Cloud → 恢复」,从保护性备份中恢复后再让自动同步继续。',
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
@@ -765,7 +774,7 @@ const zhCN: Messages = {
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
'terminal.toolbar.searchTerminal': '搜索终端 (Ctrl+F)',
'terminal.toolbar.searchTerminal': '搜索终端',
'terminal.toolbar.search': '搜索',
'terminal.toolbar.broadcast': '广播',
'terminal.toolbar.broadcastEnable': '启用广播模式',
@@ -821,6 +830,7 @@ const zhCN: Messages = {
'terminal.search.nextMatch': '下一个匹配 (Enter)',
'terminal.menu.copy': '复制',
'terminal.menu.paste': '粘贴',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
@@ -999,6 +1009,31 @@ const zhCN: Messages = {
'cloudSync.history.download': '下载',
'cloudSync.history.resolved': '已解决',
'cloudSync.history.error': '错误',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
'cloudSync.localBackups.maxCount': '最多保留',
'cloudSync.localBackups.maxSaved': '已保存保留数量:{count}',
'cloudSync.localBackups.maxInvalid': '请输入 1 到 100 之间的数字。',
'cloudSync.localBackups.empty': '还没有本地备份。',
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'cloudSync.localBackups.restore': '恢复',
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
'cloudSync.localBackups.restoreConfirmButton': '恢复',
'cloudSync.localBackups.restoreConfirmCancel': '取消',
'cloudSync.localBackups.unavailableTitle': '无法使用本地备份',
'cloudSync.localBackups.unavailableDesc': '当前平台未提供受支持的安全密钥库Netcatty 无法安全地写入本地备份。请在支持系统钥匙串的环境中运行,或改用云同步保留恢复点。',
'cloudSync.localBackups.lockedTitle': '需要主密钥',
'cloudSync.localBackups.lockedDesc': '请先配置或解锁主密钥再恢复备份,以确保恢复后的凭据仍保持加密。',
'cloudSync.revisionHistory.viewButton': '历史版本',
'cloudSync.revisionHistory.title': '主机库版本历史',
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
@@ -1748,12 +1783,16 @@ const zhCN: Messages = {
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty否则 Codex 无法鉴权。',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
@@ -1764,7 +1803,6 @@ const zhCN: Messages = {
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1797,6 +1835,17 @@ const zhCN: Messages = {
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': '用户 Skills',
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills默认只注入轻量索引只有在请求明显命中某个 skill 时才展开正文。',
'ai.userSkills.openFolder': '打开 Skills 文件夹',
'ai.userSkills.reload': '重新加载 Skills',
'ai.userSkills.location': '位置',
'ai.userSkills.loading': '正在扫描用户 skills...',
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
@@ -1851,6 +1900,7 @@ const zhCN: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',

View File

@@ -0,0 +1,467 @@
import type { SyncPayload } from '../domain/sync';
import {
STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION,
STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT,
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
export interface LocalVaultBackupPreview {
id: string;
createdAt: number;
reason: LocalVaultBackupReason;
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
}
export interface LocalVaultBackupDetails {
backup: LocalVaultBackupPreview;
payload: SyncPayload;
}
export const DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT = 20;
export const MIN_LOCAL_VAULT_BACKUP_MAX_COUNT = 1;
export const MAX_LOCAL_VAULT_BACKUP_MAX_COUNT = 100;
export const sanitizeLocalVaultBackupMaxCount = (value: number): number => {
if (!Number.isFinite(value)) return DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT;
return Math.max(
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
Math.min(MAX_LOCAL_VAULT_BACKUP_MAX_COUNT, Math.round(value)),
);
};
export const getLocalVaultBackupMaxCount = (): number => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT);
return sanitizeLocalVaultBackupMaxCount(
stored ?? DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT,
);
};
export const setLocalVaultBackupMaxCount = (value: number): number => {
const sanitized = sanitizeLocalVaultBackupMaxCount(value);
localStorageAdapter.writeNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT, sanitized);
return sanitized;
};
export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCount()): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.trimVaultBackups?.({ maxCount });
}
export async function getLocalVaultBackupCapabilities(): Promise<{
encryptionAvailable: boolean;
}> {
const bridge = netcattyBridge.get();
const caps = await bridge?.getVaultBackupCapabilities?.();
// Conservatively treat a missing bridge (non-Electron environments, early
// boot) as unavailable so callers fall back to the locked-down UI path
// instead of assuming capabilities they can't verify.
return { encryptionAvailable: Boolean(caps?.encryptionAvailable) };
}
export async function listLocalVaultBackups(): Promise<LocalVaultBackupPreview[]> {
const bridge = netcattyBridge.get();
const entries = await bridge?.listVaultBackups?.();
return Array.isArray(entries) ? entries : [];
}
export async function readLocalVaultBackup(id: string): Promise<LocalVaultBackupDetails | null> {
const bridge = netcattyBridge.get();
if (!bridge?.readVaultBackup) return null;
return bridge.readVaultBackup({ id });
}
export async function openLocalVaultBackupDir(): Promise<void> {
const bridge = netcattyBridge.get();
await bridge?.openVaultBackupDir?.();
}
export async function createLocalVaultBackup(
payload: SyncPayload,
options: {
reason: LocalVaultBackupReason;
sourceAppVersion?: string;
targetAppVersion?: string;
maxCount?: number;
},
): Promise<LocalVaultBackupPreview | null> {
// Intentional: an empty-vault backup has nothing to restore from, so we
// early-return instead of writing a zero-entry record. Callers that rely
// on a backup (protective-before-restore, version-change on first run)
// must treat `null` as "no safety net this time" and continue — blocking
// the user's flow on a missing backup would be worse than allowing the
// apply to proceed without one.
if (!hasMeaningfulSyncData(payload)) {
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
return null;
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: options.reason,
sourceAppVersion: options.sourceAppVersion,
targetAppVersion: options.targetAppVersion,
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
// The main-process bridge refuses to write backups when safeStorage is
// unavailable (VAULT_BACKUP_ENCRYPTION_UNAVAILABLE) because SyncPayload
// carries plaintext credentials that must never touch disk unencrypted.
// Callers (startup version-change, protective-before-restore) intentionally
// continue without a backup rather than blocking the user's flow, so we
// log and return null here.
const message = error instanceof Error ? error.message : String(error);
console.warn('[localVaultBackups] Backup skipped:', message);
return null;
}
}
/**
* Thrown when a caller requires a protective backup and the backup
* couldn't be written — safeStorage unavailable, bridge missing,
* main-process rejection, disk error.
*
* Callers should surface this as a user-visible abort rather than
* proceeding with the destructive apply. Separate from "nothing to
* back up" (empty vault) which is returned as `null`.
*/
export class ProtectiveBackupUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProtectiveBackupUnavailableError';
}
}
/**
* Create a protective local backup before a destructive apply (restore
* from backup list, restore from Gist revision, cloud download applied
* over meaningful local state).
*
* Returns `null` when there is nothing meaningful to back up — in that
* case the caller can safely proceed with the apply, because there is
* no local data to lose.
*
* Throws `ProtectiveBackupUnavailableError` when pre-apply state IS
* meaningful but the backup attempt failed. Callers MUST abort the
* destructive apply in that case and surface the error to the user,
* otherwise we regress the exact safety contract the backup system
* was added to enforce (the `console.error`-and-proceed pattern that
* previously swallowed safeStorage/keychain failures and continued).
*/
export async function createRequiredProtectiveLocalVaultBackup(
payload: SyncPayload,
): Promise<LocalVaultBackupPreview | null> {
if (!hasMeaningfulSyncData(payload)) {
// Nothing to protect — an empty-vault backup would produce a
// useless record, not a safety net.
return null;
}
const bridge = netcattyBridge.get();
if (!bridge?.createVaultBackup) {
throw new ProtectiveBackupUnavailableError(
'Vault backup bridge is not available in this environment.',
);
}
try {
const result = await bridge.createVaultBackup({
payload,
reason: 'before_restore',
maxCount: getLocalVaultBackupMaxCount(),
});
return result?.backup ?? null;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ProtectiveBackupUnavailableError(message);
}
}
/**
* How long each heartbeat extends the cross-window restore barrier.
* Short enough that an abandoned lock (crashed window, hung task)
* clears itself quickly without user intervention. The heartbeat
* interval below refreshes the deadline as long as the caller's task
* is still running, so large vaults or slow keychain unlocks cannot
* expose a mid-apply window to concurrent auto-sync even when the
* total apply time exceeds this value.
*/
const RESTORE_BARRIER_HOLD_MS = 60_000;
/**
* How often the heartbeat refreshes the barrier. Picked to ensure at
* least two refreshes land before the current deadline would expire,
* so a single missed tick (event-loop stall, GC pause) cannot drop
* the barrier prematurely.
*/
const RESTORE_BARRIER_HEARTBEAT_MS = Math.max(1_000, Math.floor(RESTORE_BARRIER_HOLD_MS / 3));
/**
* Run `task` while holding a cross-window "restore in progress" barrier.
*
* The barrier is a localStorage key readable by every window of the same
* origin. useAutoSync reads it on each auto-sync and on each data-change
* debounce tick, refusing to push while the deadline is still in the
* future. We write a time-bounded deadline (rather than a boolean) so a
* crashed window can never leave sync permanently wedged.
*
* While the task runs, a heartbeat timer re-writes the deadline so a
* slow apply (large vault, slow keychain) keeps the barrier held rather
* than exposing a post-deadline window to concurrent auto-sync. The
* heartbeat is cleared and the barrier is released in a finally block
* so success, throw, and unexpected early-return all converge on the
* same cleanup.
*/
export async function withRestoreBarrier<T>(
task: () => Promise<T>,
holdMs: number = RESTORE_BARRIER_HOLD_MS,
): Promise<T> {
const writeDeadline = () => {
try {
localStorageAdapter.writeNumber(
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
Date.now() + holdMs,
);
} catch (error) {
// If we can't write the barrier we still proceed — the UI-side
// `isSyncBusy` guard and same-window debounce cancellation are a
// secondary defense. Better to complete the restore than refuse on
// a broken localStorage.
console.warn('[localVaultBackups] Failed to set restore barrier:', error);
}
};
writeDeadline();
const heartbeat = setInterval(
writeDeadline,
Math.max(1_000, Math.min(holdMs / 3, RESTORE_BARRIER_HEARTBEAT_MS)),
);
try {
return await task();
} finally {
clearInterval(heartbeat);
try {
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
} catch {
/* ignore — the deadline will expire naturally */
}
}
}
/**
* Shape of the apply-in-progress sentinel record. Persisted as JSON in
* `STORAGE_KEY_VAULT_APPLY_IN_PROGRESS` so the next session can
* distinguish "the last apply completed cleanly" from "the last apply
* crashed mid-way and the local vault is a partial mix of states."
*/
export interface VaultApplyInProgressRecord {
startedAt: number;
protectiveBackupId: string | null;
}
/**
* Returns the persisted apply-in-progress record if a previous apply
* was interrupted before clearing it. Callers (notably auto-sync) use
* this to refuse to push a partial-apply local state over an intact
* cloud copy. See `applyProtectedSyncPayload` for the write side.
*
* `null` here means "no interrupted apply detected" — either nothing
* was ever applied, or the last apply finished cleanly.
*/
export function readInterruptedVaultApply(): VaultApplyInProgressRecord | null {
try {
const raw = localStorageAdapter.readString(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;
const protectiveBackupId =
typeof parsed.protectiveBackupId === 'string' ? parsed.protectiveBackupId : null;
if (!startedAt) return null;
return { startedAt, protectiveBackupId };
} catch {
return null;
}
}
/**
* Clears the apply-in-progress sentinel. The normal completion path
* inside `applyProtectedSyncPayload` clears it automatically; this
* export exists so the user's explicit recovery action ("I've restored
* from a backup, resume sync") can acknowledge the interrupted state
* from the UI without re-running an apply.
*/
export function clearInterruptedVaultApply(): void {
try {
localStorageAdapter.remove(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
} catch {
/* ignore — next clean apply will overwrite */
}
}
function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void {
try {
localStorageAdapter.writeString(
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
JSON.stringify(record),
);
} catch (error) {
// Sentinel write is best-effort: a failure here means a later crash
// won't be detected, but does NOT compromise the apply itself.
// Log so a systematic storage outage is diagnosable.
console.warn('[localVaultBackups] Failed to set apply-in-progress sentinel:', error);
}
}
/**
* Shared "apply a remote-sourced payload safely" helper.
*
* Holds the cross-window restore barrier, snapshots the pre-apply vault
* into a protective backup, persists an apply-in-progress sentinel, and
* only then runs the supplied `applyPayload` callback. Every destructive
* apply path (startup merge, conflict resolution, empty-vault restore,
* manual Gist-revision restore) must go through this so the protections
* can't drift out of sync between the main window and the settings
* window.
*
* The sentinel closes the partial-apply-then-crash window: `applyPayload`
* writes to several localStorage keys non-atomically (hosts, keys, port-
* forwarding rules, settings). A crash mid-sequence leaves the vault in
* a state that is neither pre-apply nor post-apply, and the next
* auto-sync would otherwise push that partial state over an intact cloud
* copy. The sentinel flags "local may be inconsistent" for the next
* session; `readInterruptedVaultApply` exposes that to callers that
* enforce "don't auto-push a half-applied vault."
*
* `buildPreApplyPayload` is invoked *before* the apply to snapshot the
* current vault. Callers pass their own React-closure builder (hosts,
* keys, port-forwarding rules) because the caller owns that state.
*
* `translateProtectiveBackupFailure` converts the
* `ProtectiveBackupUnavailableError` into a user-visible message in the
* caller's locale. It runs only on the thrown-and-caught path.
*/
export function applyProtectedSyncPayload(options: {
buildPreApplyPayload: () => SyncPayload;
applyPayload: () => void | Promise<void>;
translateProtectiveBackupFailure: (message: string) => string;
}): Promise<void> {
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
return withRestoreBarrier(async () => {
const pre = buildPreApplyPayload();
let protectiveBackupId: string | null = null;
try {
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
protectiveBackupId = backup?.id ?? null;
} catch (error) {
// Destructive apply without a working safety net is exactly the
// overwrite-without-recovery regression this module was added to
// prevent. Surface the failure to the caller; every call site
// currently aborts the apply and shows a user-visible error.
if (error instanceof ProtectiveBackupUnavailableError) {
throw new Error(translateProtectiveBackupFailure(error.message));
}
throw error;
}
// Mark the apply as in-progress. If the renderer crashes between
// the first localStorage write inside `applyPayload` and the
// successful completion below, the next session will observe this
// sentinel and refuse to auto-sync the partial state.
writeApplyInProgressSentinel({
startedAt: Date.now(),
protectiveBackupId,
});
// Only clear the sentinel on successful completion. A throw from
// `applyPayload` deliberately leaves the sentinel set: the partial
// write is still on disk, and the next session must observe the
// flag so auto-sync refuses to push the half-applied state.
await applyPayload();
clearInterruptedVaultApply();
});
}
export async function ensureVersionChangeBackup(
payload: SyncPayload,
currentAppVersion: string | null | undefined,
): Promise<{ created: boolean; backup: LocalVaultBackupPreview | null }> {
const normalizedVersion = currentAppVersion?.trim() || '';
if (!normalizedVersion) {
return { created: false, backup: null };
}
const previousVersion =
localStorageAdapter.readString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION)?.trim() || '';
if (!previousVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
return { created: false, backup: null };
}
if (previousVersion === normalizedVersion) {
return { created: false, backup: null };
}
let backup: LocalVaultBackupPreview | null = null;
const payloadIsMeaningful = hasMeaningfulSyncData(payload);
if (payloadIsMeaningful) {
backup = await createLocalVaultBackup(payload, {
reason: 'app_version_change',
sourceAppVersion: previousVersion,
targetAppVersion: normalizedVersion,
});
}
// Only advance the stored version stamp when we actually wrote a
// backup. Two failure modes we must NOT collapse into "advance":
//
// 1. Meaningful payload + backup failed (transient keychain lock,
// disk error) — leaving the stamp unchanged means the next
// launch retries, instead of turning a transient error into a
// permanent "the version-change backup never happened" hole.
//
// 2. Non-meaningful payload at the moment we checked — on startup
// the async vault rehydrate may not have finished yet, so
// `hasMeaningfulSyncData` can return false transiently even
// though the user has real data. Advancing in that window would
// burn the one-shot upgrade opportunity; holding keeps the
// retry available on the next launch when rehydrate has
// completed (or when the user genuinely starts from empty and
// the next migration-boundary arrives).
//
// Trade-off: a user who truly starts empty and never adds data will
// hit this branch on every launch until they do. That's cheap (a
// single meaningful-data check) and strictly safer than silently
// skipping the first real upgrade backup.
const shouldAdvanceVersion = payloadIsMeaningful && backup !== null;
if (shouldAdvanceVersion) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
}
return {
created: Boolean(backup),
backup,
};
}

View File

@@ -0,0 +1,281 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activateDraftView,
clearScopeDraftState,
createEmptyDraft,
ensureDraftForScopeState,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
setDraftView,
setSessionView,
updateDraftForScope,
} from "./aiDraftState.ts";
test("createEmptyDraft seeds selected agent and empty inputs", () => {
const draft = createEmptyDraft("agent-alpha");
assert.equal(draft.agentId, "agent-alpha");
assert.equal(draft.text, "");
assert.deepEqual(draft.attachments, []);
assert.deepEqual(draft.selectedUserSkillSlugs, []);
assert.equal(typeof draft.updatedAt, "number");
});
test("resolvePanelView defaults to draft when no explicit view exists", () => {
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
});
test("setDraftView records draft mode", () => {
assert.deepEqual(setDraftView({}, "terminal:123"), {
"terminal:123": { mode: "draft" },
});
});
test("activateDraftView clears the terminal scope's active session owner", () => {
const activeSessionIdMap = {
"terminal:123": "session-123",
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "session", sessionId: "session-123" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.deepEqual(next.activeSessionIdMap, {
"workspace:abc": "session-workspace",
});
assert.deepEqual(next.panelViewByScope, {
"terminal:123": { mode: "draft" },
"workspace:abc": panelViewByScope["workspace:abc"],
});
});
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
const activeSessionIdMap = {
"workspace:abc": "session-workspace",
};
const panelViewByScope = {
"terminal:123": { mode: "draft" },
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = activateDraftView(
activeSessionIdMap,
panelViewByScope,
"terminal:123",
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("setSessionView records target session id", () => {
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
"workspace:abc": { mode: "session", sessionId: "session-123" },
});
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-123" },
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
assert.deepEqual(next.draftsByScope, {
"workspace:2": draftsByScope["workspace:2"],
});
assert.deepEqual(next.panelViewByScope, {
"workspace:2": panelViewByScope["workspace:2"],
});
});
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"workspace:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = updateDraftForScope(
draftsByScope,
"terminal:1",
"agent-alpha",
(draft) => ({
...draft,
text: "hello world",
}),
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "hello world");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
const draftsByScope = {
"workspace:2": createEmptyDraft("agent-beta"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-alpha",
);
assert.equal(next["terminal:1"].agentId, "agent-alpha");
assert.equal(next["terminal:1"].text, "");
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
});
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),
};
const next = ensureDraftForScopeState(
draftsByScope,
"terminal:1",
"agent-beta",
);
assert.equal(next, draftsByScope);
});
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-1" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
const activeSessionIdMap = {
"terminal:closed": "session-closed",
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),
"terminal:open": createEmptyDraft("agent-beta"),
"workspace:keep": createEmptyDraft("agent-gamma"),
};
const panelViewByScope = {
"terminal:closed": { mode: "draft" },
"terminal:open": { mode: "session", sessionId: "session-open" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open": draftsByScope["terminal:open"],
"workspace:keep": draftsByScope["workspace:keep"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open": panelViewByScope["terminal:open"],
"workspace:keep": panelViewByScope["workspace:keep"],
});
});
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
const activeSessionIdMap = {
"terminal:open": "session-open",
"workspace:keep": "session-workspace",
};
const draftsByScope = {
"terminal:open": createEmptyDraft("agent-alpha"),
"workspace:keep": createEmptyDraft("agent-beta"),
};
const panelViewByScope = {
"terminal:open": { mode: "draft" },
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneTerminalTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open"]),
);
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
assert.equal(next.draftsByScope, draftsByScope);
assert.equal(next.panelViewByScope, panelViewByScope);
});

View File

@@ -0,0 +1,221 @@
import type {
AIDraft,
AIPanelView,
} from '../../infrastructure/ai/types';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
export function createEmptyDraft(agentId: string): AIDraft {
return {
text: '',
agentId,
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
};
}
export function resolvePanelView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): AIPanelView {
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
}
export function setDraftView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
): PanelViewByScope {
const currentPanelView = panelViewByScope[scopeKey];
if (currentPanelView?.mode === 'draft') {
return panelViewByScope;
}
return {
...panelViewByScope,
[scopeKey]: DEFAULT_PANEL_VIEW,
};
}
export function activateDraftView(
activeSessionIdMap: ActiveSessionIdMap,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
activeSessionIdMap: ActiveSessionIdMap;
panelViewByScope: PanelViewByScope;
} {
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
if (!hasActiveSession) {
return {
activeSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
const nextActiveSessionIdMap = { ...activeSessionIdMap };
delete nextActiveSessionIdMap[scopeKey];
return {
activeSessionIdMap: nextActiveSessionIdMap,
panelViewByScope: nextPanelViewByScope,
};
}
export function setSessionView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
sessionId: string,
): PanelViewByScope {
return {
...panelViewByScope,
[scopeKey]: { mode: 'session', sessionId },
};
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): DraftsByScope {
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
const nextDraft = updater(currentDraft);
return {
...draftsByScope,
[scopeKey]: nextDraft,
};
}
export function ensureDraftForScopeState(
draftsByScope: DraftsByScope,
scopeKey: string,
agentId: string,
): DraftsByScope {
if (draftsByScope[scopeKey]) {
return draftsByScope;
}
return {
...draftsByScope,
[scopeKey]: createEmptyDraft(agentId),
};
}
export function clearScopeDraftState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
scopeKey: string,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
if (!hasDraft && !hasPanelView) {
return {
draftsByScope,
panelViewByScope,
};
}
return {
draftsByScope: hasDraft
? (() => {
const nextDrafts = { ...draftsByScope };
delete nextDrafts[scopeKey];
return nextDrafts;
})()
: draftsByScope,
panelViewByScope: hasPanelView
? (() => {
const nextPanelViews = { ...panelViewByScope };
delete nextPanelViews[scopeKey];
return nextPanelViews;
})()
: panelViewByScope,
};
}
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
if (!scopeKey.startsWith('terminal:')) return false;
const targetId = scopeKey.slice('terminal:'.length);
if (!targetId) return false;
return !activeTerminalTargetIds.has(targetId);
}
export function pruneTerminalScopeState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
export function pruneTerminalTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTerminalTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextTerminalScopeState = pruneTerminalScopeState(
draftsByScope,
panelViewByScope,
activeTerminalTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextTerminalScopeState.draftsByScope,
panelViewByScope: nextTerminalScopeState.panelViewByScope,
};
}

View File

@@ -0,0 +1,131 @@
import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
id,
title: id,
agentId: "catty",
scope,
messages: [],
externalSessionId,
createdAt: 1,
updatedAt: 1,
};
}
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 removes non-restorable terminal chats and closed workspaces", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}, "ext-1"),
createSession("terminal-local", {
type: "terminal",
targetId: "closed-local",
hostIds: ["local-shell"],
}, "ext-2"),
createSession("workspace-closed", {
type: "workspace",
targetId: "closed-workspace",
}, "ext-3"),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, [
"terminal-restorable",
"terminal-local",
"workspace-closed",
]);
assert.deepEqual(next.sessions, [
{
...sessions[0],
externalSessionId: undefined,
},
sessions[3],
]);
});
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
targetId: "closed-restorable",
hostIds: ["host-1"],
}),
createSession("terminal-open", {
type: "terminal",
targetId: "open-terminal",
hostIds: ["host-2"],
}, "ext-4"),
];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["open-terminal"]),
);
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
assert.equal(next.sessions, sessions);
});

View File

@@ -0,0 +1,144 @@
import type {
AIDraft,
AIPanelView,
AISession,
} from "../../infrastructure/ai/types";
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
activeTargetIds: Set<string>,
): boolean {
const separatorIndex = scopeKey.indexOf(":");
if (separatorIndex === -1) return false;
const scopeType = scopeKey.slice(0, separatorIndex);
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return false;
return !activeTargetIds.has(targetId);
}
export function pruneInactiveScopedState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
const nextDraftsByScope = { ...draftsByScope };
const nextPanelViewByScope = { ...panelViewByScope };
let draftsChanged = false;
let panelViewsChanged = false;
for (const scopeKey of Object.keys(nextDraftsByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextDraftsByScope[scopeKey];
draftsChanged = true;
}
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
delete nextPanelViewByScope[scopeKey];
panelViewsChanged = true;
}
return {
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
};
}
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,
};
}
function isRestorableTerminalSession(session: AISession): boolean {
return session.scope.type === "terminal"
&& !!session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
): {
sessions: AISession[];
orphanedSessionIds: string[];
} {
const orphanedSessionIds = sessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (orphanedSessionIds.length === 0) {
return {
sessions,
orphanedSessionIds,
};
}
const orphanedSessionIdSet = new Set(orphanedSessionIds);
let sessionsChanged = false;
const nextSessions = sessions.flatMap((session) => {
if (!orphanedSessionIdSet.has(session.id)) {
return [session];
}
if (!isRestorableTerminalSession(session)) {
sessionsChanged = true;
return [];
}
if (!session.externalSessionId) {
return [session];
}
sessionsChanged = true;
return [
{ ...session, externalSessionId: undefined },
];
});
return {
sessions: sessionsChanged ? nextSessions : sessions,
orphanedSessionIds,
};
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from "react";
import React, { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";

View File

@@ -18,6 +18,8 @@ import {
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
import type {
AIDraft,
AIPanelView,
AISession,
AIPermissionMode,
AIToolIntegrationMode,
@@ -29,6 +31,18 @@ import type {
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
activateDraftView,
clearScopeDraftState,
ensureDraftForScopeState,
setSessionView,
updateDraftForScope,
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from './aiScopeCleanup';
import { convertFilesToUploads } from './useFileUpload';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
interface AIBridge {
@@ -45,6 +59,11 @@ function getAIBridge() {
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
@@ -72,48 +91,22 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const orphanedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
);
if (orphanedSessionIds.length > 0) {
const orphanedSessionIdSet = new Set(orphanedSessionIds);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
}
// Determine which sessions can be restored via host-based matching
const preservedIds = new Set<string>();
for (const session of currentSessions) {
if (!orphanedSessionIdSet.has(session.id)) continue;
// Only preserve remote terminal sessions with real hostIds
const isRestorable = session.scope.type === 'terminal'
&& session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
if (isRestorable) {
preservedIds.add(session.id);
}
}
// Cleanup ACP sessions for all orphans (both deleted and preserved).
// Preserved sessions will get a new externalSessionId on next use,
// so cleaning the old one is safe and prevents subprocess leaks.
cleanupAcpSessions(orphanedSessionIds);
const nextSessions = currentSessions
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
.map((session) => {
if (!preservedIds.has(session.id) || !session.externalSessionId) {
return session;
}
// Drop transient ACP session handles so the next turn starts cleanly.
return { ...session, externalSessionId: undefined };
});
const sessionsChanged = nextSessions.length !== currentSessions.length
|| nextSessions.some((session, index) => session !== currentSessions[index]);
if (sessionsChanged) {
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
localStorageAdapter.write(
STORAGE_KEY_AI_SESSIONS,
pruneSessionsForStorage(nextSessionCleanup.sessions),
);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
@@ -133,6 +126,45 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
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 currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
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)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
@@ -163,6 +195,9 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
let latestAISessionsSnapshot: AISession[] | null = null;
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
@@ -185,6 +220,25 @@ function areHostIdsEqual(left?: string[], right?: string[]) {
return leftIds.every((hostId) => rightSet.has(hostId));
}
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
function getDraftMutationVersion(scopeKey: string) {
return latestAIDraftMutationVersionByScopeSnapshot[scopeKey] ?? 0;
}
function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = {
...latestAIDraftMutationVersionByScopeSnapshot,
[scopeKey]: getDraftMutationVersion(scopeKey) + 1,
};
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -243,6 +297,14 @@ export function useAIState() {
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
);
// Per-scope draft/view state is intentionally memory-only so a relaunch
// does not restore stale composer input or panel intent against new history.
const [draftsByScope, setDraftsByScopeRaw] = useState<DraftsByScope>(() =>
latestAIDraftsByScopeSnapshot ?? {}
);
const [panelViewByScope, setPanelViewByScopeRaw] = useState<PanelViewByScope>(() =>
latestAIPanelViewByScopeSnapshot ?? {}
);
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
@@ -262,6 +324,14 @@ export function useAIState() {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
setLatestAIDraftsByScopeSnapshot(draftsByScope);
}, [draftsByScope]);
useEffect(() => {
setLatestAIPanelViewByScopeSnapshot(panelViewByScope);
}, [panelViewByScope]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
let changed = false;
@@ -284,13 +354,39 @@ export function useAIState() {
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === id) {
return prev;
}
const next = { ...prev, [scopeKey]: id };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
nextActiveSessionIdMap = next;
return next;
});
if (!nextActiveSessionIdMap) return;
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}, []);
const setPanelViewByScope = useCallback((value: PanelViewByScope | ((prev: PanelViewByScope) => PanelViewByScope)) => {
let nextPanelViewByScope: PanelViewByScope | null = null;
setPanelViewByScopeRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
if (next === prev) return prev;
nextPanelViewByScope = next;
return next;
});
if (!nextPanelViewByScope) return;
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
@@ -522,6 +618,12 @@ export function useAIState() {
?? {},
);
return;
case AI_STATE_CHANGED_DRAFTS_BY_SCOPE:
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
return;
case AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE:
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
return;
default:
handleStorage({ key } as StorageEvent);
}
@@ -705,7 +807,6 @@ export function useAIState() {
const next = prev.map((session) => {
if (session.id !== sessionId) return session;
changed = true;
// Clear stale ACP handle — retarget may run before orphan cleanup
return { ...session, scope, externalSessionId: undefined };
});
@@ -808,14 +909,184 @@ export function useAIState() {
});
}, [persistSessions]);
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
let nextDraftsByScope: DraftsByScope | null = null;
setDraftsByScopeRaw((prev) => {
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
if (next === prev) return prev;
nextDraftsByScope = next;
return next;
});
if (!nextDraftsByScope) return;
bumpDraftMutationVersion(scopeKey);
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}, []);
const updateDraft = useCallback((
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): void => {
setDraftsByScopeRaw((prev) => {
const next = updateDraftForScope(
prev,
scopeKey,
fallbackAgentId,
(draft) => {
return {
...updater(draft),
updatedAt: Date.now(),
};
},
);
setLatestAIDraftsByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
return next;
});
}, []);
const updateDraftIfPresent = useCallback((
scopeKey: string,
updater: (draft: AIDraft) => AIDraft,
): void => {
setDraftsByScopeRaw((prev) => {
const currentDraft = prev[scopeKey];
if (!currentDraft) return prev;
const nextDraft = {
...updater(currentDraft),
updatedAt: Date.now(),
};
const next = {
...prev,
[scopeKey]: nextDraft,
};
setLatestAIDraftsByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
return next;
});
}, []);
const showDraftView = useCallback((scopeKey: string) => {
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
let nextPanelViewByScope: PanelViewByScope | null = null;
let activeSessionMapChanged = false;
let panelViewChanged = false;
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
const next = activateDraftView(
prevActiveSessionIdMap,
currentPanelViewByScope,
scopeKey,
);
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
nextActiveSessionIdMap = next.activeSessionIdMap;
nextPanelViewByScope = next.panelViewByScope;
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
});
if (activeSessionMapChanged && nextActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (panelViewChanged && nextPanelViewByScope) {
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
setPanelViewByScopeRaw(nextPanelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}, [panelViewByScope]);
const showSessionView = useCallback((scopeKey: string, sessionId: string) => {
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
}, [setPanelViewByScope]);
const clearDraftForScope = useCallback((scopeKey: string) => {
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
let nextDraftsByScope: DraftsByScope | null = null;
let nextPanelViewByScope: PanelViewByScope | null = null;
let draftsChanged = false;
let panelViewChanged = false;
setDraftsByScopeRaw((prevDraftsByScope) => {
const next = clearScopeDraftState(
prevDraftsByScope,
currentPanelViewByScope,
scopeKey,
);
draftsChanged = next.draftsByScope !== prevDraftsByScope;
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
nextDraftsByScope = next.draftsByScope;
nextPanelViewByScope = next.panelViewByScope;
return draftsChanged ? next.draftsByScope : prevDraftsByScope;
});
if (!draftsChanged && !panelViewChanged) return;
bumpDraftMutationVersion(scopeKey);
if (draftsChanged && nextDraftsByScope) {
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (panelViewChanged && nextPanelViewByScope) {
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
setPanelViewByScopeRaw(nextPanelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}, [panelViewByScope]);
const addDraftFiles = useCallback(async (
scopeKey: string,
fallbackAgentId: string,
inputFiles: File[],
) => {
ensureDraftForScope(scopeKey, fallbackAgentId);
const initialMutationVersion = getDraftMutationVersion(scopeKey);
const uploads = await convertFilesToUploads(inputFiles);
if (uploads.length === 0) return;
if (getDraftMutationVersion(scopeKey) !== initialMutationVersion) {
return;
}
updateDraftIfPresent(scopeKey, (draft) => ({
...draft,
attachments: [...draft.attachments, ...uploads],
}));
}, [ensureDraftForScope, updateDraftIfPresent]);
const removeDraftFile = useCallback((scopeKey: string, fallbackAgentId: string, fileId: string) => {
updateDraft(scopeKey, fallbackAgentId, (draft) => ({
...draft,
attachments: draft.attachments.filter((file) => file.id !== fileId),
}));
}, [updateDraft]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
cleanupOrphanedAISessions(activeTargetIds);
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
const nextSessions =
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
}, []);
// ── Provider CRUD helpers ──
@@ -889,7 +1160,16 @@ export function useAIState() {
// Sessions (per-scope active session)
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId,
ensureDraftForScope,
updateDraft,
showDraftView,
showSessionView,
clearDraftForScope,
addDraftFiles,
removeDraftFile,
createSession,
deleteSession,
deleteSessionsByTarget,

View File

@@ -16,38 +16,16 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { notify } from '../notification';
/**
* Check whether a sync payload has any meaningful user data. Covers all
* synced entity arrays so that edge cases (e.g. user has 0 hosts but 1
* port forwarding rule) are not mistakenly treated as "empty".
*/
function isPayloadEffectivelyEmpty(payload: SyncPayload): boolean {
// Check all synced entity arrays.
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return false;
// Also consider settings: if any key has a defined value, the user has
// customized something worth preserving.
if (payload.settings && Object.values(payload.settings).some((v) => v !== undefined)) {
return false;
}
return true;
}
interface AutoSyncConfig {
// Data to sync
hosts: SyncPayload['hosts'];
@@ -61,15 +39,24 @@ interface AutoSyncConfig {
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
startupReady?: boolean;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
}
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
// in the future means a restore is applying in some window and auto-sync
// must not push concurrently.
const isRestoreInProgress = (): boolean => {
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
return typeof raw === 'number' && raw > Date.now();
};
type SyncTrigger = 'auto' | 'manual';
interface SyncNowOptions {
@@ -190,6 +177,50 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.alreadySyncing'));
}
// Cross-window guard: another window may be in the middle of
// applying a local vault restore. If we push right now we'd upload
// the pre-restore snapshot (the main window's React state hasn't
// observed the localStorage writes yet), clobbering the just-
// restored cloud copy. Skip silently on auto triggers and fail
// loudly on manual ones so the user understands why their click
// did nothing.
//
// Pairs with `withRestoreBarrier` in application/localVaultBackups.ts
// (the writer) and with the matching early-return in the
// debounced-sync effect below (the other reader, which prevents
// scheduling a push while the barrier is held).
if (isRestoreInProgress()) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
return;
}
throw new Error(t('sync.autoSync.restoreInProgress'));
}
// Refuse to auto-push when a previous apply crashed mid-way and
// left the vault in a partial state. `applyProtectedSyncPayload`
// sets a sentinel before its non-atomic localStorage writes and
// clears it on successful completion; the sentinel's presence
// here means the renderer crashed between a first write and the
// clean-up, so the in-memory payload is a mix of pre-apply and
// post-apply entries. Pushing that would silently overwrite an
// intact cloud copy with corrupted data.
//
// Manual triggers surface a user-visible error that points the
// user at the Restore UI; auto triggers return quietly (the
// next startup toast below flags the state).
const interruptedApply = readInterruptedVaultApply();
if (interruptedApply) {
if (trigger === 'auto') {
console.warn(
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
interruptedApply,
);
return;
}
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
}
// If another window unlocked, reuse the in-memory session password from main process.
if (state.securityState !== 'UNLOCKED') {
const bridge = netcattyBridge.get();
@@ -221,7 +252,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// storage corruption) rather than a deliberate "delete everything".
// We only block auto-sync — manual trigger from Settings can still
// push if the user explicitly wants to.
if (isPayloadEffectivelyEmpty(payload) && trigger === 'auto') {
//
// This pairs with the inspect-failure "fail open" behavior in
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload) && trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
}
@@ -232,7 +268,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// state gets updated even when some providers failed
for (const result of results.values()) {
if (result.mergedPayload) {
onApplyPayload(result.mergedPayload);
await Promise.resolve(onApplyPayload(result.mergedPayload));
skipNextSyncRef.current = true;
break; // All providers share the same merged payload
}
@@ -248,6 +284,18 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
lastSyncedDataRef.current = dataHash;
// Successful sync implies a successful per-provider
// `checkProviderConflict` (which inspects remote) — equivalent
// to a successful startup reconciliation from the auto-sync
// gate's point of view. Opening the gate here is the escape
// hatch when a network outage exhausted the startup retry
// timer: a user-triggered manual sync (or any first successful
// auto sync that somehow ran anyway) resumes auto-sync for the
// rest of the session. Without this, a degraded-startup session
// would require the user to manually sync after every edit.
hasCheckedRemoteRef.current = true;
remoteCheckDoneRef.current = true;
} catch (error) {
if (trigger === 'manual') {
throw error;
@@ -261,81 +309,219 @@ export const useAutoSync = (config: AutoSyncConfig) => {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
// One-shot toast per mount when a previous apply was interrupted, so the
// user understands why auto-sync is silently paused and where to go to
// recover. `applyProtectedSyncPayload` clears the sentinel on a clean
// apply, so this only fires once per genuine crash and naturally stops
// after the user completes a recovery.
const interruptedApplyNotifiedRef = useRef(false);
useEffect(() => {
if (interruptedApplyNotifiedRef.current) return;
if (!sync.isUnlocked) return;
const interrupted = readInterruptedVaultApply();
if (!interrupted) return;
interruptedApplyNotifiedRef.current = true;
notify.error(
t('sync.autoSync.interruptedApplyMessage'),
t('sync.autoSync.interruptedApplyTitle'),
);
}, [sync.isUnlocked, t]);
// Stabilize the fields `checkRemoteVersion` reads from `config`.
// AutoSyncConfig is a fresh object literal on every App render, so a
// naive `config` dep would rebuild `checkRemoteVersion`'s identity on
// every unrelated state change — re-firing the retry effect with
// `attempt=0` and spawning overlapping in-flight inspections. The
// refs below let `checkRemoteVersion` read the latest callback and
// readiness flag without pulling the object identity into deps.
const onApplyPayloadRef = useRef(config.onApplyPayload);
useEffect(() => {
onApplyPayloadRef.current = config.onApplyPayload;
}, [config.onApplyPayload]);
const startupReadyRef = useRef(config.startupReady);
useEffect(() => {
startupReadyRef.current = config.startupReady;
}, [config.startupReady]);
// `buildPayload` closes over live React state so its identity flips
// on every vault edit; route it through a ref so `checkRemoteVersion`
// can read the latest builder without churning its memo identity.
const buildPayloadRef = useRef(buildPayload);
useEffect(() => {
buildPayloadRef.current = buildPayload;
}, [buildPayload]);
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
// could both write-then-clear the apply-in-progress sentinel around
// interleaved applies, and both could push post-merge snapshots to
// remote. The cross-window `withRestoreBarrier` protects other
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
if (checkRemoteInFlightRef.current) {
return;
}
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
return;
}
hasCheckedRemoteRef.current = true;
// Find connected provider
// Find connected provider BEFORE acquiring the in-flight lock so the
// "nothing to check" early return doesn't leak the lock and wedge
// the retry timer. Any path that takes the lock MUST reach the
// finally-release below.
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) return;
if (!connectedProvider) {
// Nothing to check — mark as done so the auto-sync gate opens.
remoteCheckDoneRef.current = true;
return;
}
checkRemoteInFlightRef.current = true;
// Track whether the startup path completed in a state where the anchor/base
// are consistent with the local vault. Only then should we latch
// hasCheckedRemoteRef so that transient failures are retryable.
let startupConsistent = false;
try {
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
const base = await manager.loadSyncBase(connectedProvider);
const remotePayload = await sync.downloadFromProvider(connectedProvider);
const inspection = await manager.inspectProviderRemote(connectedProvider);
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
const localPayload = buildPayload();
const localIsEmpty = isPayloadEffectivelyEmpty(localPayload);
const remoteHasData = !isPayloadEffectivelyEmpty(remotePayload);
if (!inspection.payload || !inspection.remoteChanged || !inspection.remoteFile) {
// Remote unchanged (or empty) — no local mutation needed; anchor/base
// are already in sync with remote from a previous run.
startupConsistent = true;
return;
}
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
// Pause and ask the user what to do instead of silently merging.
if (localIsEmpty && remoteHasData) {
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
emptyVaultResolveRef.current = resolve;
setEmptyVaultConflict({
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
const remoteFile = inspection.remoteFile;
const remotePayload = inspection.payload;
const localPayload = buildPayloadRef.current();
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
const remoteHasData = hasMeaningfulSyncData(remotePayload);
// If local vault is empty but cloud has data, this almost certainly
// means the user's data was lost (update, storage corruption, etc.).
// Pause and ask the user what to do instead of silently merging.
if (localIsEmpty && remoteHasData) {
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
emptyVaultResolveRef.current = resolve;
setEmptyVaultConflict({
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
setEmptyVaultConflict(null);
emptyVaultResolveRef.current = null;
});
setEmptyVaultConflict(null);
emptyVaultResolveRef.current = null;
if (userAction === 'restore') {
config.onApplyPayload(remotePayload);
skipNextSyncRef.current = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Don't apply remote data.
// The next auto-sync will eventually push the empty state if
// the user makes another edit.
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
}
return;
if (userAction === 'restore') {
// Apply remote FIRST; only commit anchor/base after the UI-side
// state has accepted the remote payload, otherwise a failure
// between commit and apply would leave the anchor pointing at
// remote while local is still empty — the exact overwrite window
// we're trying to close.
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
skipNextSyncRef.current = true;
startupConsistent = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Deliberately do NOT advance
// the anchor or base — the next sync must still treat remote as
// "unseen" so the empty-vault-push guard (`hasMeaningfulSyncData`)
// keeps protecting the cloud copy. startupConsistent stays false
// so hasCheckedRemoteRef is not latched and the next startup will
// re-prompt if the user still has not added anything.
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
}
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
config.onApplyPayload(mergeResult.payload);
// Prevent the data-change effect from immediately re-uploading the
// merged payload — the merge already incorporated both sides. The
// next deliberate edit by the user will trigger a normal sync.
skipNextSyncRef.current = true;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
// Apply merged payload to local state BEFORE committing. If the apply
// throws, the next startup will re-run the merge with fresh data.
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
// stores remotePayload as the base so the next diff is computed
// against what the cloud actually has, not against the merged
// local-only state.
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
// If the three-way merge introduced any local-only additions that the
// remote does not yet have, we MUST round-trip those to the cloud.
// Previously this branch stopped after applying merge locally, so the
// merged-in additions lived only on the device that ran the merge
// until the user's next edit.
//
// We push the merged payload *directly* through the manager rather
// than going through the React-state-driven `syncNow`. syncNow
// rebuilds the payload from hooks state, which may not yet reflect
// the onApplyPayload we awaited above (React commit phase is async
// relative to the awaited promise resolution). Passing mergeResult
// in explicitly removes the race entirely and avoids a setTimeout(0)
// that only approximated the correct ordering.
if (mergeResult.payload) {
try {
await manager.syncAllProviders(mergeResult.payload);
// Suppress the debounced follow-up tick that otherwise fires
// once React commits the applied state, since we've just
// already pushed that exact payload upstream.
skipNextSyncRef.current = true;
} catch (error) {
// Non-fatal: the next user edit will drive another sync cycle.
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
}
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
remoteCheckDoneRef.current = true;
if (startupConsistent) {
hasCheckedRemoteRef.current = true;
// Only open the auto-sync gate when the inspect actually
// validated the remote state. Leaving the gate closed on
// inspect failure is intentional: an edit made during a
// degraded startup must not race ahead and push a partially-
// hydrated vault over an intact remote. The retry effect
// below re-fires checkRemoteVersion on the next provider/
// unlock/startupReady transition, and a manual sync from
// Settings remains available as an escape hatch.
remoteCheckDoneRef.current = true;
}
checkRemoteInFlightRef.current = false;
}
}, [sync, config, buildPayload, t]);
// Intentionally minimal deps: `buildPayload`, `config.onApplyPayload`,
// and `config.startupReady` are read through refs above so their
// identity flips (every vault edit produces a fresh `buildPayload`
// and a fresh AutoSyncConfig literal) cannot re-memoize this
// callback and restart the retry-timer's exponential backoff.
}, [t]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -379,6 +565,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Hold off on scheduling a new push while another window is applying
// a restore — the restore is about to land via localStorage and the
// debounce-fired syncNow would otherwise race it. The next data-
// change tick after the restore barrier clears will re-enter here.
if (isRestoreInProgress()) {
return;
}
// Don't even schedule a push while the apply-in-progress sentinel
// is held. The syncNow path re-checks and refuses too, but dropping
// the debounced schedule here avoids spinning a 3-second timer for
// every keystroke while the user is in the Restore UI working
// through recovery.
if (readInterruptedVaultApply()) {
return;
}
// Clear existing timeout
if (syncTimeoutRef.current) {
@@ -397,17 +600,65 @@ export const useAutoSync = (config: AutoSyncConfig) => {
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
// Check remote version on startup/unlock
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
// a failure that doesn't coincide with a dep change would wedge the
// auto-sync gate closed until the user restarts or manually triggers
// sync from Settings — the 30s/60s/90s cadence below lets a short
// outage (network blip, provider rate-limit) self-heal.
useEffect(() => {
if (sync.hasAnyConnectedProvider && sync.isUnlocked && !hasCheckedRemoteRef.current) {
// Delay check to ensure everything is loaded
const timer = setTimeout(() => {
checkRemoteVersion();
}, 1000);
return () => clearTimeout(timer);
if (
!sync.hasAnyConnectedProvider ||
!sync.isUnlocked ||
hasCheckedRemoteRef.current ||
config.startupReady === false
) {
return;
}
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
let cancelled = false;
let attempt = 0;
let timerId: NodeJS.Timeout | null = null;
const tick = () => {
if (cancelled) return;
void (async () => {
await checkRemoteVersion();
if (cancelled || hasCheckedRemoteRef.current) return;
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
// persistent failure beyond that is almost certainly a
// misconfiguration that needs user action rather than more
// auto-retries.
//
// When retries exhaust we deliberately leave the auto-sync gate
// CLOSED. Opening it here would allow a partially-lost local
// vault to silently clobber an unchanged remote: anchor still
// matches, `checkProviderConflict` sees no remote change,
// `hasMeaningfulSyncData` doesn't flag non-empty-but-partial
// local, and the empty-vault prompt never fires.
//
// Escape hatch: a successful manual sync from Settings opens
// the gate via `syncNow`'s success path. That path runs the
// same per-provider inspect we use here, so a successful
// manual sync is equivalent to a successful startup inspect
// from the gate's point of view — the user's explicit click
// authorizes both the push and the subsequent auto-sync
// resumption. Until then, auto-sync stays paused and the
// "sync paused" toast is the user's signal to act.
if (attempt >= 4) return;
const delayMs = Math.min(240_000, 30_000 * 2 ** attempt);
attempt += 1;
timerId = setTimeout(tick, delayMs);
})();
};
tick();
return () => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
// Reset check flags when provider disconnects
useEffect(() => {
@@ -416,6 +667,25 @@ export const useAutoSync = (config: AutoSyncConfig) => {
remoteCheckDoneRef.current = false;
}
}, [sync.hasAnyConnectedProvider]);
// On unmount, release any pending empty-vault confirmation. Without
// this, an unmount mid-dialog (window close, workspace switch) leaves
// the resolver promise dangling forever and the `checkRemoteVersion`
// finally block never sets remoteCheckDoneRef — in practice React
// tears down the hook first, but leaking the resolve callback and
// referenced remotePayload keeps them pinned by the awaiter until
// the next reload. Resolving with 'keep-empty' is the safe default:
// it mirrors the "don't touch remote" choice and leaves the version
// stamp untouched so the next mount re-prompts.
useEffect(() => {
return () => {
const resolve = emptyVaultResolveRef.current;
if (resolve) {
emptyVaultResolveRef.current = null;
resolve('keep-empty');
}
};
}, []);
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
// Guard: resolve only once (prevents double-click from entering an

View File

@@ -1,20 +1,13 @@
/**
* useFileUpload - Handle file paste/drop with base64 conversion
* File upload conversion helpers for AI draft attachments.
*
* Supports images, PDFs, and other document types.
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
import type { UploadedFile } from '../../infrastructure/ai/types';
import { getPathForFile } from '../../lib/sftpFileUtils';
export interface UploadedFile {
id: string;
filename: string;
dataUrl: string; // data:...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
filePath?: string; // original filesystem path (Electron only)
}
export type { UploadedFile } from '../../infrastructure/ai/types';
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
@@ -38,42 +31,32 @@ async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: str
});
}
export function useFileUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
export async function convertFilesToUploads(inputFiles: File[]): Promise<UploadedFile[]> {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return [];
const addFiles = useCallback(async (inputFiles: File[]) => {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return;
const newFiles: UploadedFile[] = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
}
const uploads: Array<UploadedFile | null> = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
try {
const result = await fileToDataUrl(file);
const filePath = getPathForFile(file);
return { id, filename, dataUrl, base64Data, mediaType, filePath };
}),
);
return {
id,
filename,
dataUrl: result.dataUrl,
base64Data: result.base64,
mediaType,
filePath,
};
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
return null;
}
}),
);
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id));
}, []);
const clearFiles = useCallback(() => {
setFiles([]);
}, []);
return { files, addFiles, removeFile, clearFiles };
return uploads.filter((upload): upload is UploadedFile => upload !== null);
}

View File

@@ -77,6 +77,7 @@ export const getTerminalPassthroughActions = (): Set<string> => {
return new Set([
'copy',
'paste',
'pasteSelection',
'selectAll',
'clearBuffer',
'searchTerminal',

View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect, useState } from 'react';
import {
type LocalVaultBackupPreview,
getLocalVaultBackupCapabilities,
getLocalVaultBackupMaxCount,
listLocalVaultBackups,
openLocalVaultBackupDir,
readLocalVaultBackup,
setLocalVaultBackupMaxCount,
trimLocalVaultBackups,
} from '../localVaultBackups';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function useLocalVaultBackups() {
const [backups, setBackups] = useState<LocalVaultBackupPreview[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [maxBackups, setMaxBackupsState] = useState(() => getLocalVaultBackupMaxCount());
// `null` while we're still asking the main process. The UI should treat
// `null` as "unknown, don't render restore controls yet" so we never expose
// a destructive action that might later be disabled.
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
const refreshBackups = useCallback(async () => {
setIsLoading(true);
try {
const next = await listLocalVaultBackups();
setBackups(next);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const caps = await getLocalVaultBackupCapabilities();
if (!cancelled) {
setEncryptionAvailable(caps.encryptionAvailable);
}
} catch {
if (!cancelled) {
setEncryptionAvailable(false);
}
}
})();
void refreshBackups();
return () => {
cancelled = true;
};
}, [refreshBackups]);
// Cross-window live refresh: the main process broadcasts when any
// renderer's createBackup or trimBackups actually mutated the on-disk
// set. Without this subscription, a protective backup written by the
// main window wouldn't show up in the Settings window's list until
// the user manually navigated away and back, silently under-reporting
// the most recent recovery points.
useEffect(() => {
const bridge = netcattyBridge.get();
const subscribe = bridge?.onVaultBackupsChanged;
if (typeof subscribe !== 'function') return undefined;
const unsubscribe = subscribe(() => {
void refreshBackups();
});
return () => {
try { unsubscribe?.(); } catch { /* ignore */ }
};
}, [refreshBackups]);
const updateMaxBackups = useCallback(async (value: number) => {
const sanitized = setLocalVaultBackupMaxCount(value);
setMaxBackupsState(sanitized);
await trimLocalVaultBackups(sanitized);
await refreshBackups();
return sanitized;
}, [refreshBackups]);
const openBackupDirectory = useCallback(async () => {
await openLocalVaultBackupDir();
}, []);
return {
backups,
isLoading,
maxBackups,
encryptionAvailable,
refreshBackups,
readBackup: readLocalVaultBackup,
setMaxBackups: updateMaxBackups,
openBackupDirectory,
};
}
export default useLocalVaultBackups;

View File

@@ -34,7 +34,9 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -71,6 +73,9 @@ const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
const DEFAULT_SHOW_RECENT_HOSTS = true;
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
const DEFAULT_SHOW_SFTP_TAB = true;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -260,6 +265,18 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [showRecentHosts, setShowRecentHostsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
});
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
});
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -463,6 +480,12 @@ export const useSettingsState = () => {
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -662,6 +685,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
});
@@ -671,6 +695,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
};
@@ -834,6 +859,24 @@ export const useSettingsState = () => {
setSftpDefaultViewMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showRecentHosts) {
setShowRecentHostsState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
setShowOnlyUngroupedHostsInRootState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showSftpTab) {
setShowSftpTabState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -923,6 +966,27 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
}, [notifySettingsChanged]);
const setShowRecentHosts = useCallback((enabled: boolean) => {
setShowRecentHostsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
}, [notifySettingsChanged]);
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
setShowOnlyUngroupedHostsInRootState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
}, [notifySettingsChanged]);
const setShowSftpTab = useCallback((enabled: boolean) => {
setShowSftpTabState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
// Always apply CSS to document (needed on mount)
@@ -1228,6 +1292,12 @@ export const useSettingsState = () => {
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1266,6 +1336,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle,
]),
};

View File

@@ -102,6 +102,7 @@ const safeParse = <T,>(value: string | null): T | null => {
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [identities, setIdentities] = useState<Identity[]>([]);
@@ -339,129 +340,133 @@ export const useVaultState = () => {
useEffect(() => {
const init = async () => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
try {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}
} else {
updateHosts(INITIAL_HOSTS);
}
} else {
updateHosts(INITIAL_HOSTS);
}
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}
}
} finally {
setIsInitialized(true);
}
};
@@ -657,6 +662,7 @@ export const useVaultState = () => {
);
return {
isInitialized,
hosts,
keys,
identities,

View File

@@ -43,6 +43,8 @@ import {
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -61,6 +63,29 @@ export interface SyncableVaultData {
groupConfigs?: GroupConfig[];
}
/**
* Returns true when the payload contains any meaningful user data worth
* protecting or syncing.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
@@ -173,6 +198,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
if (showRecent != null) settings.showRecentHosts = showRecent;
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -238,6 +267,15 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// Immersive mode (legacy — always enabled, ignore incoming value)
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
if (settings.showOnlyUngroupedHostsInRoot != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
settings.showOnlyUngroupedHostsInRoot,
);
}
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
}
// ---------------------------------------------------------------------------

View File

@@ -19,8 +19,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '../lib/utils';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AIDraft,
AIPanelView,
AIPermissionMode,
AIToolIntegrationMode,
AISession,
@@ -32,6 +33,7 @@ import type {
WebSearchConfig,
} from '../infrastructure/ai/types';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
@@ -39,6 +41,18 @@ import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import {
getReadyUserSkillOptions,
getNextSelectedUserSkillSlugsMap,
type UserSkillOption,
} from './ai/userSkillsState';
import {
applyHistorySessionSelection,
resolveDisplayedPanelView,
resolveDisplayedSession,
shouldRetargetSessionForScope,
} from './ai/aiPanelViewState';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
getNetcattyBridge,
@@ -70,7 +84,20 @@ interface AIChatSidePanelProps {
// Session state (per-scope)
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
draftsByScope: Partial<Record<string, AIDraft>>;
panelViewByScope: Partial<Record<string, AIPanelView>>;
setActiveSessionId: (scopeKey: string, id: string | null) => void;
ensureDraftForScope: (scopeKey: string, agentId: string) => void;
updateDraft: (
scopeKey: string,
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
) => void;
showDraftView: (scopeKey: string) => void;
showSessionView: (scopeKey: string, sessionId: string) => void;
clearDraftForScope: (scopeKey: string) => void;
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
@@ -146,11 +173,11 @@ function generateId(): string {
}
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
return messages.flatMap((message) => {
return messages.flatMap((message): Array<{ role: 'user' | 'assistant'; content: string }> => {
if (message.role === 'system') return [];
if (message.role === 'user') {
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
return message.content ? [{ role: 'user', content: message.content }] : [];
}
if (message.role === 'assistant') {
@@ -160,12 +187,12 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
if (!parts.length) return [];
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
return [{ role: 'assistant', content: parts.join('\n\n') }];
}
if (message.role === 'tool' && message.toolResults?.length) {
return message.toolResults.map((tr) => ({
role: 'assistant' as const,
role: 'assistant',
content: `Tool result:\n${tr.content}`,
}));
}
@@ -202,7 +229,16 @@ function getSessionScopeMatchRank(
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId: setActiveSessionIdForScope,
ensureDraftForScope,
updateDraft,
showDraftView,
showSessionView,
clearDraftForScope,
addDraftFiles,
removeDraftFile,
createSession,
deleteSession,
updateSessionTitle,
@@ -238,18 +274,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// Derive scope key for per-scope isolation
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
// Per-scope input values
const [inputValueMap, setInputValueMap] = useState<Record<string, string>>({});
const inputValue = inputValueMap[scopeKey] ?? '';
const setInputValue = useCallback((val: string) => {
setInputValueMap(prev => ({ ...prev, [scopeKey]: val }));
}, [scopeKey]);
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
@@ -271,9 +298,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
updateMessageById,
});
// Per-scope active session ID
const activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
const setActiveSessionId = useCallback((id: string | null) => {
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
@@ -302,15 +326,28 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
);
const activeSession = useMemo(() => {
if (activeSessionIdForScope) {
const session = sessions.find((s) => s.id === activeSessionIdForScope);
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
return session;
}
}
return historySessions[0] ?? null;
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
const explicitPanelView = panelViewByScope[scopeKey];
const currentDraft = draftsByScope[scopeKey] ?? null;
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
const normalizedPanelView = useMemo<AIPanelView>(
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId),
[explicitPanelView, currentDraft, historySessions, persistedSessionId],
);
const activeSession = useMemo(
() => resolveDisplayedSession(normalizedPanelView, historySessions),
[normalizedPanelView, historySessions],
);
const activeSessionId = normalizedPanelView.mode === 'session' ? normalizedPanelView.sessionId : null;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const currentAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? defaultAgentId;
const inputValue = currentDraft?.text ?? '';
const files = currentDraft?.attachments ?? [];
const panelViewRef = useRef(normalizedPanelView);
panelViewRef.current = normalizedPanelView;
const currentDraftRef = useRef(currentDraft);
currentDraftRef.current = currentDraft;
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
@@ -335,32 +372,33 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return undefined;
}, [terminalSessions, scopeType, scopeTargetId]);
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions) {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}
}, [terminalSessions, scopeKey, activeSessionId]);
useEffect(() => {
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
showDraftView(scopeKey);
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
const shouldRetargetActiveSession = useMemo(() => {
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
return false;
}
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
return false;
}
// Don't retarget sessions that are actively owned by another terminal
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
return false;
}
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
return shouldRetargetSessionForScope(
activeSession,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalTargetIds,
);
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
useEffect(() => {
if (!activeSession) return;
if (shouldRetargetActiveSession && isVisible) {
// Full cleanup of any in-flight work — the session came from a disconnected
// terminal, so any active response, pending approvals, or exec is dead.
if (streamingSessionIds.has(activeSession.id)) {
const controller = abortControllersRef.current.get(activeSession.id);
if (controller) {
@@ -381,12 +419,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return;
}
if (isVisible && activeSessionIdForScope !== activeSession.id) {
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
}, [
activeSession,
activeSessionIdForScope,
activeSessionIdMap,
scopeKey,
retargetSessionScope,
isVisible,
scopeHostIds,
@@ -399,20 +438,103 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
abortControllersRef,
]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSession) {
setCurrentAgentId(activeSession.agentId);
}
}, [scopeKey, activeSession]);
const ensureScopeDraft = useCallback((agentId: string) => {
ensureDraftForScope(scopeKey, agentId);
}, [ensureDraftForScope, scopeKey]);
const updateScopeDraft = useCallback((
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
) => {
updateDraft(scopeKey, fallbackAgentId, updater);
}, [scopeKey, updateDraft]);
const showScopeDraftView = useCallback(() => {
showDraftView(scopeKey);
}, [scopeKey, showDraftView]);
const showScopeSessionView = useCallback((sessionId: string) => {
showSessionView(scopeKey, sessionId);
}, [scopeKey, showSessionView]);
const clearScopeDraft = useCallback(() => {
clearDraftForScope(scopeKey);
}, [clearDraftForScope, scopeKey]);
const setInputValue = useCallback((value: string) => {
updateScopeDraft(currentAgentId, (draft) => ({
...draft,
text: value,
}));
}, [currentAgentId, updateScopeDraft]);
const addFiles = useCallback(async (inputFiles: File[]) => {
await addDraftFiles(scopeKey, currentAgentId, inputFiles);
}, [addDraftFiles, scopeKey, currentAgentId]);
const removeFile = useCallback((fileId: string) => {
removeDraftFile(scopeKey, currentAgentId, fileId);
}, [removeDraftFile, scopeKey, currentAgentId]);
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
const applyUserSkillsStatus = (result: { ok: boolean; skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}> } | null | undefined) => {
const nextOptions = getReadyUserSkillOptions(result);
setUserSkillOptions(nextOptions);
const draft = currentDraftRef.current;
if (!draft) {
return;
}
const nextSelectedUserSkillSlugs =
getNextSelectedUserSkillSlugsMap(
{ [scopeKey]: draft.selectedUserSkillSlugs },
result,
)[scopeKey] ?? [];
const selectedUserSkillsChanged =
nextSelectedUserSkillSlugs.length !== draft.selectedUserSkillSlugs.length
|| nextSelectedUserSkillSlugs.some((slug, index) => slug !== draft.selectedUserSkillSlugs[index]);
if (!selectedUserSkillsChanged) {
return;
}
updateScopeDraft(draft.agentId, (currentScopeDraft) => ({
...currentScopeDraft,
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
}));
};
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions) {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
if (!bridge?.aiUserSkillsGetStatus) {
applyUserSkillsStatus(null);
return;
}
}, [terminalSessions, scopeKey, activeSessionId]);
void bridge.aiUserSkillsGetStatus()
.then((result) => {
if (cancelled) return;
applyUserSkillsStatus(result);
})
.catch(() => {
if (cancelled) return;
applyUserSkillsStatus(null);
});
return () => {
cancelled = true;
};
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
// Sync provider configs to main process so it can decrypt API keys server-side.
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
@@ -458,6 +580,18 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
);
const messages = activeSession?.messages ?? [];
const selectedUserSkillSlugs = useMemo(
() => currentDraft?.selectedUserSkillSlugs ?? [],
[currentDraft],
);
const selectedUserSkills = useMemo(
() =>
selectedUserSkillSlugs.map((slug) => {
const option = userSkillOptions.find((skill) => skill.slug === slug);
return option ?? { id: slug, slug, name: slug, description: '' };
}),
[selectedUserSkillSlugs, userSkillOptions],
);
// ── Export hook ──
const { handleExport } = useConversationExport(activeSession);
@@ -480,13 +614,60 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
const isCodexManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
[currentAgentConfig],
);
const isClaudeManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'claude') : false,
[currentAgentConfig],
);
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
// so the picker can show just that model instead of the hardcoded ChatGPT
// preset list. Probing codex-acp for its full catalog returns the stock
// OpenAI models regardless of the active provider, which is misleading.
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
return;
}
const bridge = getNetcattyBridge();
if (!bridge?.aiCodexGetIntegration) return;
let cancelled = false;
void Promise.resolve(
bridge.aiCodexGetIntegration() as Promise<CodexIntegrationStatus>,
).then((info) => {
if (cancelled) return;
const hasCustom = info?.state === 'connected_custom_config';
setCodexConfigModel(info?.customConfig?.model ?? null);
// Only flip "resolved" to true when the probe confirms this is a
// custom-config session; otherwise keep it false so we fall back to
// the static CODEX_MODEL_PRESETS.
setCodexCustomConfigResolved(hasCustom);
}).catch(() => {
if (!cancelled) {
setCodexConfigModel(null);
setCodexCustomConfigResolved(false);
}
});
return () => { cancelled = true; };
}, [isCodexManagedAgent, currentAgentId]);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
if (!isCopilotExternalAgent) return;
// Codex has its own path via aiCodexGetIntegration (reads config.toml).
// Everyone else that speaks ACP can be asked for their available models
// directly — in particular, Claude Code through claude-agent-acp
// advertises the real catalog (including Bedrock/Vertex model ids when
// the user configured those) instead of the hardcoded CLAUDE_MODEL_PRESETS.
if (!isCopilotExternalAgent && !isClaudeManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
@@ -500,6 +681,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
`models_${currentAgentId}`,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
// If the probe came back empty, drop any stale cached catalog for this
// agent so `agentModelPresets` falls back to the hardcoded presets via
// the `?? getAgentModelPresets(...)` branch. Without this, a previously
// successful probe would keep surfacing models the backend no longer
// advertises.
if (result.models.length === 0) {
setRuntimeAgentModelPresets((prev) => {
if (!(currentAgentId in prev)) return prev;
const { [currentAgentId]: _removed, ...rest } = prev;
return rest;
});
return;
}
const knownModelIds = new Set(result.models.map((model) => model.id));
setRuntimeAgentModelPresets((prev) => ({
...prev,
@@ -518,12 +712,28 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
const agentModelPresets = useMemo(
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
);
// When Codex is backed by a ~/.codex/config.toml custom provider, the
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
// codexCustomConfigResolved (declared above alongside codexConfigModel)
// stays false until the integration probe confirms this session is
// custom-config, so we don't flash an empty picker while loading.
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
if (hasCodexCustomConfig) {
// Config.toml with a pinned model → show just that model.
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
// Config.toml custom provider without a pinned model → codex-acp
// uses its provider default. Don't surface the OpenAI presets; they
// wouldn't work. Empty list disables the picker.
return [];
}
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
@@ -551,24 +761,17 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// -------------------------------------------------------------------
const handleNewChat = useCallback(() => {
const scope: AISessionScope = {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
};
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
clearScopeDraft();
updateScopeDraft(currentAgentId, () => ({
text: '',
agentId: currentAgentId,
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: Date.now(),
}));
showScopeDraftView();
setShowHistory(false);
setInputValue('');
}, [
scopeType,
scopeTargetId,
scopeHostIds,
currentAgentId,
createSession,
setActiveSessionId,
setInputValue,
]);
}, [clearScopeDraft, currentAgentId, showScopeDraftView, updateScopeDraft]);
const handleOpenSettings = useCallback(() => {
void openSettingsWindow();
@@ -582,12 +785,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
const inputValueRef = useRef(inputValue);
inputValueRef.current = inputValue;
const filesRef = useRef(files);
filesRef.current = files;
/** Auto-title a session from the first user message if untitled. */
const autoTitleSession = useCallback((sessionId: string, text: string) => {
const s = sessionsRef.current.find(x => x.id === sessionId);
@@ -610,62 +807,89 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
};
}, []);
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
if (shouldRetargetActiveSession) {
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
} else if (activeSessionIdForScope !== activeSession.id) {
setActiveSessionId(activeSession.id);
const addSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
updateScopeDraft(currentAgentId, (draft) => {
if (draft.selectedUserSkillSlugs.includes(normalizedSlug)) {
return draft;
}
return activeSession.id;
}
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
return session.id;
}, [
activeSession,
activeSessionIdForScope,
createSession,
currentAgentId,
retargetSessionScope,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
shouldRetargetActiveSession,
]);
return {
...draft,
selectedUserSkillSlugs: [...draft.selectedUserSkillSlugs, normalizedSlug],
};
});
}, [currentAgentId, updateScopeDraft]);
const removeSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
updateScopeDraft(currentAgentId, (draft) => {
const nextSelectedUserSkillSlugs = draft.selectedUserSkillSlugs.filter(
(entry) => entry !== normalizedSlug,
);
if (nextSelectedUserSkillSlugs.length === draft.selectedUserSkillSlugs.length) {
return draft;
}
return {
...draft,
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
};
});
}, [currentAgentId, updateScopeDraft]);
// -------------------------------------------------------------------
// Main send handler (thin orchestrator)
// -------------------------------------------------------------------
const handleSend = useCallback(async () => {
const trimmed = inputValueRef.current.trim();
const draft = currentDraftRef.current;
const currentPanelView = panelViewRef.current;
const currentSessionView = activeSessionRef.current;
const trimmed = draft?.text.trim() ?? '';
const sendScopeKey = scopeKey;
// Double-submit protection currently relies on the draft being cleared
// immediately after the first send path starts; `isStreaming` alone does
// not protect the initial draft->session transition.
if (!trimmed || isStreaming) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const attachments = (draft?.attachments ?? []).map((file) => ({
base64Data: file.base64Data,
mediaType: file.mediaType,
filename: file.filename,
filePath: file.filePath,
}));
const isExternalAgent = currentAgentId !== 'catty';
let sessionId = currentSessionView?.id ?? null;
let currentSession = currentSessionView ?? null;
let sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
// No provider configured for built-in agent
if (!isExternalAgent && !activeProvider) {
const errSessionId = ensureSession();
addMessageToSession(errSessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(errSessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
setInputValue('');
if (currentPanelView.mode === 'draft') {
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const createdSession = createSession(scope, sendAgentId);
sessionId = createdSession.id;
currentSession = createdSession;
clearScopeDraft();
showScopeSessionView(createdSession.id);
setActiveSessionId(createdSession.id);
}
if (!sessionId) {
return;
}
// Ensure session exists
const sessionId = ensureSession();
const isExternalAgent = sendAgentId !== 'catty';
// Capture images before clearing
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
// No provider configured for built-in agent
if (!isExternalAgent && !activeProvider) {
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft();
showScopeSessionView(sessionId);
}
return;
}
// Add user message
addMessageToSession(sessionId, {
@@ -673,12 +897,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
setInputValue('');
clearFiles();
clearScopeDraft();
showScopeSessionView(sessionId);
setActiveSessionId(sessionId);
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find(a => a.id === currentAgentId) : undefined;
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
@@ -690,7 +915,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const abortController = new AbortController();
abortControllersRef.current.set(sessionId, abortController);
const currentSession = sessionsRef.current.find(s => s.id === sessionId);
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
if (isExternalAgent) {
if (!agentConfig) {
@@ -708,6 +933,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
providers,
selectedAgentModel,
toolIntegrationMode,
selectedUserSkillSlugs: selectedSkillSlugs,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
@@ -735,17 +961,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
}, attachments.length > 0 ? attachments : undefined);
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
activeModelId, externalAgents,
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearFiles,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
clearScopeDraft, showScopeSessionView, setActiveSessionId,
]);
const handleStop = useCallback(() => {
@@ -770,15 +998,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const handleSelectSession = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
// Restore agent selector to match the session's bound agent
const session = sessions.find((s) => s.id === sessionId);
if (session) {
setCurrentAgentId(session.agentId);
}
setShowHistory(false);
applyHistorySessionSelection(sessionId, {
showSessionView: showScopeSessionView,
setActiveSessionId,
closeHistory: () => setShowHistory(false),
});
},
[setActiveSessionId, sessions],
[setActiveSessionId, showScopeSessionView],
);
const handleDeleteSession = useCallback(
@@ -791,12 +1017,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
);
const handleAgentChange = useCallback((agentId: string) => {
setCurrentAgentId(agentId);
// Preserve the current session in history and start a new one with the selected agent
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, agentId);
setActiveSessionId(session.id);
}, [scopeType, scopeTargetId, scopeHostIds, createSession, setActiveSessionId]);
ensureScopeDraft(agentId);
updateScopeDraft(agentId, (draft) => ({
...draft,
agentId,
}));
showScopeDraftView();
setShowHistory(false);
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
// -------------------------------------------------------------------
// Render
@@ -908,6 +1136,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>

View File

@@ -17,6 +17,7 @@ import {
Download,
Database,
ExternalLink,
FolderOpen,
Eye,
EyeOff,
Github,
@@ -32,6 +33,12 @@ import {
X,
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { useLocalVaultBackups } from '../application/state/useLocalVaultBackups';
import {
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
withRestoreBarrier,
} from '../application/localVaultBackups';
import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
@@ -628,10 +635,395 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}
interface LocalBackupsPanelProps {
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
/**
* When true, the panel hides the Restore button entirely — e.g. while the
* master key has not been configured yet, a restore would land credentials
* on disk in plaintext (I3). Listing is still allowed so users can see that
* their history exists.
*/
restoreDisabledReason?: 'no-master-key' | null;
}
const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
onApplyPayload,
restoreDisabledReason = null,
}) => {
const { t, resolvedLocale } = useI18n();
const {
backups,
isLoading,
maxBackups,
encryptionAvailable,
refreshBackups,
readBackup,
setMaxBackups,
openBackupDirectory,
} = useLocalVaultBackups();
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
// users from wiping their vault with a single accidental click (I2).
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
(typeof backups)[number] | null
>(null);
useEffect(() => {
setMaxBackupsInput(String(maxBackups));
}, [maxBackups]);
const formatTimestamp = (timestamp: number) =>
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
reason === 'app_version_change'
? t('cloudSync.localBackups.reason.appVersionChange')
: t('cloudSync.localBackups.reason.beforeRestore');
const handleSaveMaxBackups = async () => {
// Validate BEFORE calling setMaxBackups, which hands off to the
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
// modes must be surfaced rather than silently clamped, because
// both produce a misleading "saved" toast:
//
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
// sanitize clamps to the default (20). A user who meant to
// clear the field then re-type would see their retention
// silently reset to 20 with a success message.
//
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
// still reports success, but the visible error string says
// "between 1 and 100", so the user has no idea their value
// was changed. Reject explicitly instead.
//
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
// in vaultBackupBridge.cjs so renderer and bridge agree.
const parsed = Number(maxBackupsInput);
const inRange =
Number.isFinite(parsed) &&
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
if (!inRange || maxBackupsInput.trim() === '') {
toast.error(
t('cloudSync.localBackups.maxInvalid'),
t('sync.toast.errorTitle'),
);
return;
}
setIsSavingMaxBackups(true);
try {
const next = await setMaxBackups(parsed);
setMaxBackupsInput(String(next));
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
} finally {
setIsSavingMaxBackups(false);
}
};
const handleOpenBackupDirectory = async () => {
try {
await openBackupDirectory();
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.toast.errorTitle'),
);
}
};
const performRestore = async (backupId: string) => {
setRestoringBackupId(backupId);
try {
// Hold the cross-window restore barrier around both the load
// and the apply so another window's auto-sync cannot push a
// pre-restore snapshot concurrently. See `withRestoreBarrier`
// in application/localVaultBackups.ts for the read-side in
// useAutoSync.
//
// In-memory React state refresh is implicit: `onApplyPayload`
// (supplied by the hosting screen) routes through
// `applySyncPayload` → `importDataFromString` → store writes
// → the hook-store listeners in `useVaultState` /
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
// lists here because a future refactor that decouples those
// stores from the apply path would silently break the UI
// refresh in a way that's only visible after a manual
// restart. Any change to that chain must either preserve
// store-listener notification OR add an explicit
// `rehydrateAllFromStorage` call here — do not assume
// restore is "just" a payload swap.
await withRestoreBarrier(async () => {
const detail = await readBackup(backupId);
if (!detail) {
throw new Error(t('cloudSync.localBackups.restoreMissing'));
}
await Promise.resolve(onApplyPayload(detail.payload));
});
await refreshBackups();
toast.success(t('cloudSync.localBackups.restoreSuccess'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.localBackups.restoreFailedTitle'),
);
} finally {
setRestoringBackupId(null);
}
};
const restoreAllowed = restoreDisabledReason === null;
// While encryptionAvailable is still `null` we're mid-probe — render the
// restore button as disabled so the user never sees a path they can't
// actually take (I1 surface). Once resolved, `false` hides the panel body
// via the unavailable banner below.
const encryptionResolved = encryptionAvailable !== null;
const encryptionUsable = encryptionAvailable === true;
// safeStorage probe finished and returned "not available" → disable the
// panel entirely; the main process refuses to write in this state (I1).
if (encryptionResolved && !encryptionUsable) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle size={16} />
<span className="text-sm font-medium">
{t('cloudSync.localBackups.unavailableTitle')}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.unavailableDesc')}
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-lg">
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.retentionDesc')}
</div>
</div>
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
<div className="flex items-end gap-2 md:justify-end">
<Input
type="number"
min={1}
max={100}
value={maxBackupsInput}
onChange={(e) => setMaxBackupsInput(e.target.value)}
className="w-28"
/>
<Button
variant="outline"
onClick={() => void handleSaveMaxBackups()}
disabled={isSavingMaxBackups}
className="gap-2"
>
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
{t('common.save')}
</Button>
</div>
</div>
</div>
</div>
{!restoreAllowed && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
<AlertTriangle size={14} />
<span className="font-medium">
{t('cloudSync.localBackups.lockedTitle')}
</span>
</div>
{t('cloudSync.localBackups.lockedDesc')}
</div>
)}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.desc')}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => void refreshBackups()}
disabled={isLoading}
className="gap-1"
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
{t('settings.system.refresh')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void handleOpenBackupDirectory()}
className="gap-1"
>
<FolderOpen size={14} />
{t('settings.system.openFolder')}
</Button>
</div>
</div>
{backups.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
{t('cloudSync.localBackups.empty')}
</div>
) : (
<div className="space-y-2">
{backups.map((backup) => (
<div
key={backup.id}
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{getReasonLabel(backup.reason)}
</span>
<span className="text-xs text-muted-foreground">
{formatTimestamp(backup.createdAt)}
</span>
{backup.sourceAppVersion && backup.targetAppVersion && (
<span className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.counts', {
hosts: String(backup.preview.hostCount),
keys: String(backup.preview.keyCount),
snippets: String(backup.preview.snippetCount),
})}
</div>
</div>
{restoreAllowed && (
<Button
size="sm"
variant="outline"
onClick={() => setPendingRestoreBackup(backup)}
// Disable every row while ANY restore is in
// flight. Each restore runs a full
// `applyProtectedSyncPayload` — multiple
// localStorage writes + the apply-in-progress
// sentinel. `withRestoreBarrier` serializes
// across windows but does NOT serialize
// same-window re-entry, so two overlapping
// clicks here would interleave destructive
// writes and the second run's sentinel-clear
// could mask a still-partial first apply.
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId === backup.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restore')}
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Restore confirmation dialog (I2). Keeps the destructive action
gated behind an explicit second click, mirroring the clear-local
dialog elsewhere in this screen. */}
<Dialog
open={pendingRestoreBackup !== null}
onOpenChange={(open) => {
if (!open) setPendingRestoreBackup(null);
}}
>
<DialogContent className="sm:max-w-[440px] z-[70]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t('cloudSync.localBackups.restoreConfirmTitle')}
</DialogTitle>
<DialogDescription>
{t('cloudSync.localBackups.restoreConfirmDesc')}
</DialogDescription>
</DialogHeader>
{pendingRestoreBackup && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">
{getReasonLabel(pendingRestoreBackup.reason)}
</span>
<span className="text-muted-foreground">
{formatTimestamp(pendingRestoreBackup.createdAt)}
</span>
</div>
<div className="text-muted-foreground">
{t('cloudSync.localBackups.counts', {
hosts: String(pendingRestoreBackup.preview.hostCount),
keys: String(pendingRestoreBackup.preview.keyCount),
snippets: String(pendingRestoreBackup.preview.snippetCount),
})}
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPendingRestoreBackup(null)}
disabled={restoringBackupId !== null}
>
{t('cloudSync.localBackups.restoreConfirmCancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const target = pendingRestoreBackup;
if (!target) return;
setPendingRestoreBackup(null);
await performRestore(target.id);
}}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId !== null ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
{t('cloudSync.localBackups.restoreConfirmButton')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
@@ -1012,7 +1404,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
if (result.success) {
// Apply merged data if a three-way merge happened
if (result.mergedPayload && onApplyPayload) {
onApplyPayload(result.mergedPayload);
await Promise.resolve(onApplyPayload(result.mergedPayload));
}
toast.success(t('cloudSync.sync.success', { provider }));
} else if (result.conflictDetected) {
@@ -1030,13 +1422,28 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
try {
const payload = await sync.resolveConflict(resolution);
if (payload && resolution === 'USE_REMOTE') {
onApplyPayload(payload);
// USE_REMOTE applies cloud data over local — same data-loss
// shape as a local backup restore, so gate auto-sync in
// every other window the same way.
await withRestoreBarrier(async () => {
await Promise.resolve(onApplyPayload(payload));
});
toast.success(t('cloudSync.resolve.downloaded'));
} else if (resolution === 'USE_LOCAL') {
// Re-sync with local data
// Re-sync with local data. Hold the same cross-window
// restore barrier that USE_REMOTE uses: without it, a
// concurrent auto-sync tick in another window can slip
// between our conflict resolution and the upload,
// producing a second upload path with stale state that
// races against this push. USE_LOCAL doesn't mutate the
// renderer's in-memory state (no onApplyPayload call), so
// the barrier is belt-and-suspenders against the other
// window's push, not ours.
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
await sync.syncNow(localPayload);
await withRestoreBarrier(async () => {
await sync.syncNow(localPayload);
});
toast.success(t('cloudSync.resolve.uploaded'));
}
setShowConflictModal(false);
@@ -1094,9 +1501,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
};
const handleRestoreRevision = () => {
const handleRestoreRevision = async () => {
if (!historyPreview) return;
onApplyPayload(historyPreview.payload);
// Gist revision restore is a destructive "replace local with cloud
// snapshot" op — same shape as a local backup restore, same
// cross-window race to block.
await withRestoreBarrier(async () => {
await Promise.resolve(onApplyPayload(historyPreview.payload));
});
toast.success(t('cloudSync.revisionHistory.restored'));
setShowHistoryModal(false);
setHistoryPreview(null);
@@ -1327,6 +1739,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
)}
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
/>
{/* Clear Local Data */}
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
<div className="flex items-center justify-between">
@@ -1955,7 +2371,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload;
onApplyPayload: (payload: SyncPayload) => void;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onClearLocalData?: () => void;
}
@@ -1965,7 +2381,19 @@ export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
// so users don't have to manage a separate LOCKED screen.
if (securityState === 'NO_KEY') {
return <GatekeeperScreen onSetupComplete={() => { }} />;
return (
<div className="space-y-6">
<GatekeeperScreen onSetupComplete={() => { }} />
{/* The master key is not configured yet. Expose the backup
history for diagnostic purposes but refuse restores: the
vault encryption layer can't re-protect the restored
credentials until the user finishes master-key setup (I3). */}
<LocalBackupsPanel
onApplyPayload={props.onApplyPayload}
restoreDisabledReason="no-master-key"
/>
</div>
);
}
return <SyncDashboard {...props} />;

View File

@@ -67,27 +67,27 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
<div className="space-y-2">
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
autoFocus
/>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -99,7 +99,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<div className="p-2 space-y-1">
{filteredHosts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
{t('common.noResults', 'No hosts found')}
{t('common.noResults', { defaultValue: 'No hosts found' })}
</div>
) : (
filteredHosts.map(host => {
@@ -126,15 +126,15 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
</ScrollArea>
</div>
<div className="text-xs text-muted-foreground text-right">
{selectedHostIds.size} {t('common.selected', 'selected')}
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
{t('common.create', 'Create')}
{t('common.create', { defaultValue: 'Create' })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -70,6 +70,7 @@ interface QuickSwitcherProps {
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
// onCreateWorkspace removed - feature not currently used
keyBindings?: KeyBinding[];
showSftpTab: boolean;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -84,6 +85,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose,
onCreateLocalTerminal,
keyBindings,
showSftpTab,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
@@ -161,7 +163,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
);
// Tabs (built-in + sessions + workspaces)
items.push({ type: "tab", id: "vault" });
items.push({ type: "tab", id: "sftp" });
if (showSftpTab) items.push({ type: "tab", id: "sftp" });
orphanSessions.forEach((s) =>
items.push({ type: "tab", id: s.id, data: s }),
);
@@ -194,7 +196,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
});
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
@@ -317,7 +319,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
{/* Built-in tabs */}
{["vault", "sftp"].map((tabId) => {
{(showSftpTab ? ["vault", "sftp"] : ["vault"]).map((tabId) => {
const idx = getItemIndex("tab", tabId);
const isSelected = idx === selectedIndex;
const icon =

View File

@@ -41,7 +41,7 @@ class AITabErrorBoundary extends React.Component<
</div>
);
}
return this.props.children;
return (this.props as { children: React.ReactNode }).children;
}
}
@@ -286,6 +286,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
showRecentHosts={settings.showRecentHosts}
setShowRecentHosts={settings.setShowRecentHosts}
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
/>
)}

View File

@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
@@ -245,6 +246,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
// Token for an in-flight retry chain. handleRetry sets this to a fresh
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
// chained xterm.write callbacks verify the token before proceeding so a
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
@@ -684,6 +690,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const teardown = () => {
retryTokenRef.current = null;
cleanupSession();
xtermRuntimeRef.current?.dispose();
xtermRuntimeRef.current = null;
@@ -989,7 +996,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
termRef.current.options.scrollback = terminalSettings.scrollback;
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
@@ -1398,6 +1405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCancelConnect = () => {
retryTokenRef.current = null;
setIsCancelling(true);
auth.setNeedsAuth(false);
auth.setAuthRetryMessage(null);
@@ -1417,6 +1425,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleCloseDisconnectedSession = () => {
retryTokenRef.current = null;
onCloseSession?.(sessionId);
};
@@ -1458,10 +1467,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
// Reset terminal state: disable mouse tracking modes and clear screen so
// stale SGR mouse sequences don't leak into the new session as text input.
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
termRef.current.reset();
const term = termRef.current;
// Claim a fresh retry token. If the user cancels / closes / unmounts /
// kicks off another retry while the chained writes below are still
// queued, the token will be invalidated and our callbacks will abort
// before opening a ghost backend session with no owning UI.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
@@ -1470,17 +1484,51 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setShowLogs(true);
if (host.protocol === "serial") {
sessionStarters.startSerial(termRef.current);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(termRef.current);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(termRef.current);
} else if (host.moshEnabled) {
sessionStarters.startMosh(termRef.current);
} else {
sessionStarters.startSSH(termRef.current);
}
const startNewSession = () => {
if (!retryStillActive()) return;
if (host.protocol === "serial") {
sessionStarters.startSerial(term);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(term);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(term);
} else if (host.moshEnabled) {
sessionStarters.startMosh(term);
} else {
sessionStarters.startSSH(term);
}
};
// Chain the whole preparation through xterm.write callbacks so everything
// lands in strict order — see #695. xterm.write is async, so without
// chaining, a fast reconnect path (local/serial especially) can interleave
// the new session's first bytes with our reset sequence, corrupting the
// first screen.
//
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
// we must be on the normal buffer before preserving.
term.write('\x1b[?1049l', () => {
if (!retryStillActive()) return;
// 2. Push the previous session's viewport into scrollback so the user
// can still read it after reconnect.
preserveTerminalViewportInScrollback(term);
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
// full-screen apps may have left on — DECCKM (otherwise arrow keys
// emit SS3 and break readline history), keypad mode, SGR,
// insert/replace, origin, cursor visibility — without clearing the
// buffer. DECSTR does not cover xterm-specific extensions, so also
// explicitly disable mouse tracking (1000/1002/1003/1006) and
// bracketed paste (2004). Finally home the cursor.
term.write(
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
// 4. Only now — after every prep byte has been applied to the
// terminal — start the new session, so its first output can't
// interleave with the reset sequence.
startNewSession,
);
});
};
const shouldShowConnectionDialog = status !== "connected"
@@ -1615,6 +1663,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isAlternateScreen={hasMouseTracking}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onPasteSelection={terminalContextActions.onPasteSelection}
onSelectAll={terminalContextActions.onSelectAll}
onClear={terminalContextActions.onClear}
onSelectWord={terminalContextActions.onSelectWord}

View File

@@ -33,6 +33,7 @@ import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKe
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
@@ -40,7 +41,7 @@ import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { AIChatSidePanel } from './AIChatSidePanel';
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
import { useAIState } from '../application/state/useAIState';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
@@ -259,6 +260,10 @@ interface AIChatPanelsHostProps {
}) => ExecutorContext;
}
interface AIStateMaintenanceHostProps {
validAIScopeTargetIds: Set<string>;
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiState = useAIState();
return (
@@ -271,6 +276,27 @@ const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ childre
const AIStateProvider = memo(AIStateProviderInner);
AIStateProvider.displayName = 'AIStateProvider';
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
validAIScopeTargetIds,
}) => {
const aiState = useContext(AIStateContext);
if (!aiState) {
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
}
const { cleanupOrphanedSessions } = aiState;
useEffect(() => {
cleanupOrphanedSessions(validAIScopeTargetIds);
}, [cleanupOrphanedSessions, validAIScopeTargetIds]);
return null;
};
const AIStateMaintenanceHost = memo(AIStateMaintenanceHostInner);
AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost';
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
@@ -300,7 +326,16 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
<AIChatSidePanel
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
draftsByScope={aiState.draftsByScope}
panelViewByScope={aiState.panelViewByScope}
setActiveSessionId={aiState.setActiveSessionId}
ensureDraftForScope={aiState.ensureDraftForScope}
updateDraft={aiState.updateDraft}
showDraftView={aiState.showDraftView}
showSessionView={aiState.showSessionView}
clearDraftForScope={aiState.clearDraftForScope}
addDraftFiles={aiState.addDraftFiles}
removeDraftFile={aiState.removeDraftFile}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
@@ -852,7 +887,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return map;
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
const validTerminalTabIds = useMemo(() => {
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
for (const session of sessions) ids.add(session.id);
for (const workspace of workspaces) ids.add(workspace.id);
@@ -940,16 +975,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [workspaces]);
useEffect(() => {
setSidePanelOpenTabs(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
sessionActivityStore.prune(validSessionActivityIds);
}, [validSessionActivityIds, validTerminalTabIds]);
useEffect(() => {
cleanupOrphanedAISessions(validTerminalTabIds);
}, [validTerminalTabIds]);
}, [validSessionActivityIds, validAIScopeTargetIds]);
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
if (!workspace) return {} as Record<string, WorkspaceRect>;
@@ -1646,8 +1677,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// recomputing scope resolution from scratch on every tab switch.
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
const tabIds = new Set<string>(mountedAiTabIds);
if (activeTabId) tabIds.add(activeTabId);
@@ -1919,6 +1950,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return (
<AIStateProvider>
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
@@ -1973,7 +2005,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="sftp"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
@@ -1987,7 +2022,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="scripts"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
@@ -2001,7 +2039,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="theme"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
@@ -2015,7 +2056,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
data-tab-id="ai"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'

View File

@@ -44,6 +44,7 @@ interface TopTabsProps {
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
showSftpTab: boolean;
}
// Detect local OS for local terminal tab icons
@@ -251,6 +252,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
showSftpTab,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
@@ -498,6 +500,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuTrigger asChild>
<div
data-tab-id={session.id}
data-tab-type="session"
data-state={activeTabId === session.id ? 'active' : 'inactive'}
onClick={() => onSelectTab(session.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, session.id)}
@@ -506,7 +510,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
@@ -597,6 +601,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuTrigger asChild>
<div
data-tab-id={workspace.id}
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
onClick={() => onSelectTab(workspace.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, workspace.id)}
@@ -605,7 +611,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
@@ -695,9 +701,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<div
key={logView.id}
data-tab-id={logView.id}
data-tab-type="logView"
data-state={isActive ? 'active' : 'inactive'}
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
)}
style={{
backgroundColor: isActive
@@ -785,9 +793,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
<div
data-tab-id="vault"
data-tab-type="root"
data-state={isVaultActive ? 'active' : 'inactive'}
onClick={() => onSelectTab('vault')}
className={cn(
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"netcatty-tab relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isVaultActive
@@ -812,40 +823,45 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
>
<FolderLock size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
{showSftpTab && (
<div
data-tab-id="sftp"
data-tab-type="root"
data-state={isSftpActive ? 'active' : 'inactive'}
onClick={() => onSelectTab('sftp')}
className={cn(
"netcatty-tab relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
)}
</div>
{/* Scrollable tabs container with fade masks */}
@@ -969,7 +985,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.onSyncNow === next.onSyncNow &&
prev.onToggleTheme === next.onToggleTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
prev.isImmersiveActive === next.isImmersiveActive
prev.isImmersiveActive === next.isImmersiveActive &&
prev.showSftpTab === next.showSftpTab
);
};

View File

@@ -39,7 +39,11 @@ import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig"
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED, STORAGE_KEY_SHOW_RECENT_HOSTS } from "../infrastructure/config/storageKeys";
import {
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import {
@@ -147,6 +151,8 @@ interface VaultViewProps {
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
groupConfigs: GroupConfig[];
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
// Optional: navigate to a specific section on mount or when changed
navigateToSection?: VaultSection | null;
onNavigateToSectionHandled?: () => void;
@@ -193,6 +199,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onRunSnippet,
groupConfigs,
onUpdateGroupConfigs,
showRecentHosts,
showOnlyUngroupedHostsInRoot,
navigateToSection,
onNavigateToSectionHandled,
}) => {
@@ -230,11 +238,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
// Handle external navigation requests
useEffect(() => {
if (navigateToSection) {
@@ -874,6 +877,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
return hostGroup === selectedGroupPath;
});
} else if (showOnlyUngroupedHostsInRoot) {
filtered = filtered.filter((h) => {
const hostGroup = (h.group || "").trim();
return hostGroup === "";
});
}
if (search.trim()) {
const s = search.toLowerCase();
@@ -911,7 +919,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
});
return filtered;
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
}, [hosts, selectedGroupPath, showOnlyUngroupedHostsInRoot, search, selectedTags, sortMode]);
// Pinned hosts for root-level display (not inside a subgroup)
// Respects active search and tag filters
@@ -962,6 +970,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// No longer deduplicate pinned/recent hosts from the main list,
// so hosts always appear in their groups regardless of pinned/recent status.
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
const visibleDisplayedHosts = useMemo(
() => displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)),
[displayedHosts, selectedGroupPath, pinnedRecentIds],
);
// For tree view: apply search, tag filter, and sorting, but not group filtering
const treeViewHosts = useMemo(() => {
@@ -1125,6 +1137,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- findGroupNode is derived from buildGroupTree
}, [buildGroupTree, selectedGroupPath, customGroups]);
const shouldHideEmptyRootHostsSection = useMemo(() => {
if (selectedGroupPath || viewMode === "tree") return false;
if (search.trim() || selectedTags.length > 0) return false;
if (visibleDisplayedHosts.length > 0) return false;
return (
displayedGroups.length > 0 ||
pinnedHosts.length > 0 ||
(showRecentHosts && recentHosts.length > 0)
);
}, [
selectedGroupPath,
viewMode,
search,
selectedTags.length,
visibleDisplayedHosts.length,
displayedGroups.length,
pinnedHosts.length,
showRecentHosts,
recentHosts.length,
]);
// Known Hosts callbacks - use refs to keep stable references
// Store latest values in refs so callbacks don't need to depend on them
@@ -2353,6 +2385,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</section>
{!shouldHideEmptyRootHostsSection && (
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
@@ -2360,7 +2393,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
@@ -2622,7 +2655,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
>
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
{visibleDisplayedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
@@ -2754,6 +2787,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
)}
</section>
)}
</div>
{currentSection === "snippets" && (

View File

@@ -8,7 +8,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
className={cn('relative flex-1 overflow-x-hidden overflow-y-hidden', className)}
initial="instant"
resize="smooth"
role="log"
@@ -20,7 +20,7 @@ export type ConversationContentProps = ComponentProps<typeof StickToBottom.Conte
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content
className={cn('flex flex-col gap-4 p-4', className)}
className={cn('flex min-w-0 max-w-full flex-col gap-4 overflow-x-hidden p-4', className)}
{...props}
/>
);

View File

@@ -62,7 +62,7 @@ export const MessageResponse = memo(
// Style the rendered markdown
// Code: base styles (code-block overrides are in index.css)
'[&_code]:text-[12px] [&_code]:font-mono',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
'[&_p]:my-1.5',
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -40,6 +41,7 @@ function formatToolResult(result: unknown): string {
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
className?: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;

View File

@@ -6,12 +6,11 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import type { UploadedFile } from '../../application/state/useFileUpload';
import {
PromptInput,
PromptInputFooter,
@@ -21,7 +20,11 @@ import {
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
import { ScrollArea } from '../ui/scroll-area';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
interface ChatInputProps {
value: string;
@@ -48,6 +51,14 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Callback to add a selected user skill */
onAddUserSkill?: (slug: string) => void;
/** Callback to remove a selected user skill */
onRemoveUserSkill?: (slug: string) => void;
/** Permission mode (only shown for Catty Agent) */
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
@@ -72,38 +83,74 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
selectedUserSkills = [],
userSkills = [],
onAddUserSkill,
onRemoveUserSkill,
permissionMode,
onPermissionModeChange,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showSlashSkillPicker = activeMenu === 'slashSkill';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
setActiveMenu(null);
setMenuPos(null);
setInputPanelPos(null);
setHoveredModelId(null);
setShowHostSubmenu(false);
setSlashQuery('');
setSlashRange(null);
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputShellRef = useRef<HTMLDivElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
const beforeCaret = text.slice(0, caretPosition);
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
if (!match) return null;
const start = beforeCaret.length - match[0].length + match[1].length;
return {
start,
end: beforeCaret.length,
query: String(match[2] || '').toLowerCase(),
};
}, []);
const getInputPanelMenuPos = useCallback(() => {
const rect = inputShellRef.current?.getBoundingClientRect();
if (!rect) return null;
const horizontalMargin = 12;
const safeRight = window.innerWidth - horizontalMargin;
const width = Math.min(rect.width, safeRight - rect.left);
return {
left: rect.left,
bottom: window.innerHeight - rect.top + 8,
width,
};
}, []);
const handleInputChange = useCallback((newValue: string) => {
onChange(newValue);
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
// Detect if user just typed @
if (
hosts.length > 0 &&
@@ -111,16 +158,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
newValue.endsWith('@')
) {
// Position the popover near the textarea
const el = textareaRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
}
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setActiveMenu('atMention');
} else if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
return;
}
}, [onChange, value, hosts.length, showAtMention]);
const slashTrigger = findSlashTrigger(newValue, caretPosition);
if (userSkills.length > 0 && slashTrigger) {
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setSlashQuery(slashTrigger.query);
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
setActiveMenu('slashSkill');
return;
}
if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
} else if (showSlashSkillPicker) {
closeAllMenus();
}
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
@@ -133,10 +192,45 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setInputPanelPos(pos);
if (menu === 'slashSkill') {
setSlashQuery('');
setSlashRange(null);
}
setActiveMenu(menu);
}, [getInputPanelMenuPos]);
const filteredUserSkills = userSkills.filter((skill) => {
if (!slashQuery) return true;
const lowerQuery = slashQuery.toLowerCase();
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
});
const removeSlashQueryFromInput = useCallback(() => {
if (!slashRange) return value;
const before = value.slice(0, slashRange.start);
const after = value.slice(slashRange.end);
if (/\s$/.test(before) && /^\s/.test(after)) {
return `${before}${after.slice(1)}`;
}
return `${before}${after}`;
}, [slashRange, value]);
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
onAddUserSkill?.(skill.slug);
if (slashRange) {
onChange(removeSlashQueryFromInput());
}
closeAllMenus();
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
.map((item: DataTransferItem) => item.getAsFile())
.filter((f): f is File => !!f);
if (pastedFiles.length > 0) {
e.preventDefault();
onAddFiles?.(pastedFiles);
@@ -166,21 +260,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
// Permission mode chip removed — agents run in autonomous mode
// selectedModelId may be "model/thinking" for codex
const selectedBaseModelId = selectedModelId?.split('/')[0];
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
// split on the first '/'. Match against the full id first; only treat the
// trailing segment as a thinking level when we find a preset whose
// declared thinkingLevels make the combined form equal to selectedModelId.
const { selectedPreset, selectedThinking } = (() => {
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
const direct = modelPresets.find(m => m.id === selectedModelId);
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
const viaThinking = modelPresets.find(
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
);
if (viaThinking) {
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
return { selectedPreset: viaThinking, selectedThinking: thinking };
}
return { selectedPreset: undefined, selectedThinking: undefined };
})();
const selectedBaseModelId = selectedPreset?.id;
const modelLabel = selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel');
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const selectedSkillChipClassName =
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
const iconButtonClassName =
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
return (
<div className="shrink-0 px-4 pb-4">
<div ref={inputShellRef} className="relative">
<PromptInput onSubmit={handleSubmit}>
{/* File attachment chips */}
{files.length > 0 && (
@@ -224,13 +337,43 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{selectedUserSkills.length > 0 && (
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<div
key={skill.id}
className={selectedSkillChipClassName}
title={skill.description || skill.name || skill.slug}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
))}
</div>
</div>
)}
<PromptInputTextarea
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled}
className={expanded ? 'max-h-[220px]' : undefined}
className={[
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
expanded ? 'max-h-[220px]' : undefined,
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<button
type="button"
@@ -243,31 +386,78 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
{/* @ mention popover */}
{showAtMention && hosts.length > 0 && menuPos && createPortal(
{showAtMention && hosts.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Mention host"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuHosts')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="truncate">{host.label || host.hostname}</span>
</div>
{host.label && host.hostname !== host.label ? (
<div className="mt-0.5 pl-3.5 text-[10px] text-muted-foreground/60 truncate">
{host.hostname}
</div>
) : null}
</button>
))}
</div>
</ScrollArea>
</div>
</>,
document.body,
)}
{/* / skill popover */}
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Insert user skill"
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
>
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuUserSkills')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{filteredUserSkills.map((skill) => (
<button
key={skill.id}
type="button"
role="option"
onClick={() => insertUserSkillToken(skill)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
{skill.description ? (
<div className="mt-0.5 pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
</div>
) : null}
</button>
))}
</div>
</ScrollArea>
</div>
</>,
document.body,
@@ -322,48 +512,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<div
className="relative"
onMouseEnter={() => setShowHostSubmenu(true)}
onMouseLeave={() => setShowHostSubmenu(false)}
onFocus={() => setShowHostSubmenu(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
<button
type="button"
role="menuitem"
aria-label="Mention host"
onClick={() => openInputPanelMenu('atMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{userSkills.length > 0 && (
<button
type="button"
role="menuitem"
aria-label="Mention host"
aria-expanded={showHostSubmenu && hosts.length > 0}
aria-label="Insert user skill"
onClick={() => openInputPanelMenu('slashSkill')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
<Package size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
<ChevronRight size={10} className="text-muted-foreground/50" />
</button>
{showHostSubmenu && hosts.length > 0 && (
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="menuitem"
onClick={() => {
const mention = `@${host.label || host.hostname} `;
onChange(value + mention);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
</>,
document.body,
@@ -375,7 +547,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
if (!hasModelPicker) return;
if (!showModelPicker) {
const rect = modelBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
if (rect) {
// Clamp so the popover stays inside the viewport when
// the chip is near the right edge of a narrow AI side
// panel.
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
setActiveMenu('model');
} else {
closeAllMenus();
@@ -395,8 +573,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Select model"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
onMouseLeave={() => setHoveredModelId(null)}
>
{modelPresets.map(preset => {
@@ -420,12 +598,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
>
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="flex-1 text-foreground/85">{preset.name}</span>
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
</button>
{/* Thinking level sub-menu */}
{hasThinking && hoveredModelId === preset.id && (
@@ -555,6 +732,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};

View File

@@ -177,13 +177,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
return (
<React.Fragment key={message.id}>
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
<div key={tr.toolCallId}>
<ToolCall
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
</div>
))}
</React.Fragment>
);
@@ -255,15 +256,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
@@ -308,34 +310,35 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
.map((entry) => {
const [id, req] = entry;
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
.map(([id, req]) => {
return (
<ToolCall
key={id}
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
<div key={id}>
<ToolCall
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
</div>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}

View File

@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import {
applyHistorySessionSelection,
normalizePanelView,
resolveDisplayedPanelView,
resolveDisplayedSession,
shouldRetargetSessionForScope,
} from "./aiPanelViewState.ts";
function createSession(id: string): AISession {
return {
id,
title: `Session ${id}`,
messages: [],
createdAt: 1,
updatedAt: 1,
agentId: "catty",
scope: {
type: "terminal",
targetId: "terminal-1",
},
};
}
test("draft view never falls back to most recent history", () => {
const panelView: AIPanelView = { mode: "draft" };
const sessions = [createSession("session-2"), createSession("session-1")];
assert.equal(resolveDisplayedSession(panelView, sessions), null);
});
test("session view returns the selected session", () => {
const selectedSession = createSession("session-2");
const panelView: AIPanelView = { mode: "session", sessionId: selectedSession.id };
const sessions = [createSession("session-1"), selectedSession];
assert.equal(resolveDisplayedSession(panelView, sessions), selectedSession);
});
test("missing session target resolves to null instead of history fallback", () => {
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
const sessions = [createSession("session-2"), createSession("session-1")];
assert.equal(resolveDisplayedSession(panelView, sessions), null);
});
test("missing session target normalizes back to draft view", () => {
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
});
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions),
{ mode: "session", sessionId: "session-2" },
);
});
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "session-1"),
{ mode: "session", sessionId: "session-1" },
);
});
test("persisted session id that no longer exists in history falls back to newest", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session"),
{ mode: "session", sessionId: "session-2" },
);
});
test("null persisted session id falls back to newest history entry", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, null),
{ mode: "session", sessionId: "session-2" },
);
});
test("missing explicit panel view prefers the draft when unsent input exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, true, sessions),
{ mode: "draft" },
);
});
test("draft state is used when there is no implicit history to resume", () => {
assert.deepEqual(
resolveDisplayedPanelView(undefined, true, []),
{ mode: "draft" },
);
});
test("restorable terminal history should retarget to the current scope", () => {
const session: AISession = {
...createSession("session-2"),
scope: {
type: "terminal",
targetId: "old-terminal",
hostIds: ["host-1"],
},
};
assert.equal(
shouldRetargetSessionForScope(
session,
"terminal",
"new-terminal",
["host-1"],
new Set<string>(),
),
true,
);
});
test("session owned by another active terminal should not retarget", () => {
const session: AISession = {
...createSession("session-2"),
scope: {
type: "terminal",
targetId: "other-active-terminal",
hostIds: ["host-1"],
},
};
assert.equal(
shouldRetargetSessionForScope(
session,
"terminal",
"new-terminal",
["host-1"],
new Set<string>(["other-active-terminal"]),
),
false,
);
});
test("history selection switches to the chosen session without touching draft state", () => {
const calls: string[] = [];
applyHistorySessionSelection("session-2", {
showSessionView: (sessionId) => {
calls.push(`view:${sessionId}`);
},
setActiveSessionId: (sessionId) => {
calls.push(`active:${sessionId}`);
},
closeHistory: () => {
calls.push("close-history");
},
});
assert.deepEqual(calls, [
"view:session-2",
"active:session-2",
"close-history",
]);
});

View File

@@ -0,0 +1,94 @@
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
interface HistorySessionSelectionActions {
showSessionView: (sessionId: string) => void;
setActiveSessionId: (sessionId: string) => void;
closeHistory?: () => void;
}
export function resolveDisplayedPanelView(
panelView: AIPanelView | undefined,
hasDraft: boolean,
sessions: AISession[],
persistedSessionId?: string | null,
): AIPanelView {
if (panelView) {
return normalizePanelView(panelView, sessions);
}
if (hasDraft) {
return DEFAULT_PANEL_VIEW;
}
// Honour the persisted active-session selection (survives cold mount)
// before falling back to the newest history entry.
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
return { mode: "session", sessionId: persistedSessionId };
}
if (sessions[0]) {
return { mode: "session", sessionId: sessions[0].id };
}
return DEFAULT_PANEL_VIEW;
}
export function normalizePanelView(
panelView: AIPanelView,
sessions: AISession[],
): AIPanelView {
if (panelView.mode !== "session") {
return panelView;
}
return sessions.some((session) => session.id === panelView.sessionId)
? panelView
: DEFAULT_PANEL_VIEW;
}
export function resolveDisplayedSession(
panelView: AIPanelView,
sessions: AISession[],
): AISession | null {
if (panelView.mode !== "session") {
return null;
}
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
}
export function shouldRetargetSessionForScope(
session: AISession | null,
scopeType: "terminal" | "workspace",
scopeTargetId?: string,
scopeHostIds?: string[],
activeTerminalTargetIds?: Set<string>,
): boolean {
if (!session || scopeType !== "terminal" || !scopeTargetId || !scopeHostIds?.length) {
return false;
}
if (session.scope.type !== scopeType || session.scope.targetId === scopeTargetId) {
return false;
}
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
return false;
}
return session.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
}
export function applyHistorySessionSelection(
sessionId: string,
actions: HistorySessionSelectionActions,
): void {
actions.showSessionView(sessionId);
actions.setActiveSessionId(sessionId);
actions.closeHistory?.();
}

View File

@@ -121,6 +121,17 @@ export interface PanelBridge extends NetcattyBridge {
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}>;
}>;
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
@@ -155,6 +166,45 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
interface UserSkillsContextResult {
ok: boolean;
context?: string;
error?: string;
}
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
if (!selectedUserSkillSlugs?.length) return '';
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
}
async function resolveUserSkillsContext(
bridge: PanelBridge | undefined,
prompt: string,
selectedUserSkillSlugs?: string[],
): Promise<string> {
if (!bridge?.aiUserSkillsBuildContext) {
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
.catch(() => ({ ok: false, context: '' }));
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
const result = hasExplicitSelections
? await buildContextPromise
: await Promise.race([
buildContextPromise,
new Promise<UserSkillsContextResult>((resolve) =>
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
),
]);
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
@@ -239,6 +289,7 @@ export interface SendToCattyContext {
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
selectedUserSkillSlugs?: string[];
}
/** Context values needed by sendToExternalAgent that change frequently. */
@@ -251,6 +302,7 @@ export interface SendToExternalContext {
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
}
// -------------------------------------------------------------------
@@ -542,6 +594,11 @@ export function useAIChatStreaming({
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
if (agentConfig.acpCommand && bridge) {
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
@@ -551,11 +608,6 @@ export function useAIChatStreaming({
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
// avoiding plaintext key transit across the IPC boundary.
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
const agentProviderId = openaiProvider?.id;
// Mutable flag: set after tool-result, cleared when new assistant msg is created
let needsNewAssistantMsg = false;
const maybeCreateAssistantMsg = () => {
@@ -637,19 +689,23 @@ export function useAIChatStreaming({
onDone: () => {},
},
abortController.signal,
agentProviderId,
// Managed ACP agents (codex, claude) must resolve auth from their own
// CLI config/login state, so we deliberately pass no providerId here.
// See issue #705 for Codex; same reasoning for Claude.
undefined,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
context.toolIntegrationMode,
context.defaultTargetSession,
userSkillsContext,
);
} else {
// Fallback: spawn as raw process
await runExternalAgentTurn(
agentConfig,
trimmed,
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
{
onTextDelta: (text: string) => {
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
@@ -683,6 +739,11 @@ export function useAIChatStreaming({
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
@@ -710,6 +771,7 @@ export function useAIChatStreaming({
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
userSkillsContext,
});
// Guard: activeProvider must exist for Catty agent path

View File

@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getNextSelectedUserSkillSlugsMap,
getReadyUserSkillOptions,
pruneSelectedUserSkillSlugsMap,
} from "./userSkillsState.ts";
test("getReadyUserSkillOptions returns only ready skills and clears invalid payloads", () => {
assert.deepEqual(getReadyUserSkillOptions(null), []);
assert.deepEqual(getReadyUserSkillOptions({ ok: false }), []);
assert.deepEqual(
getReadyUserSkillOptions({
ok: true,
skills: [
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
status: "ready",
},
{
id: "beta",
slug: "beta",
name: "Beta",
description: "Beta helper",
status: "warning",
},
],
}),
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
);
});
test("pruneSelectedUserSkillSlugsMap removes stale slugs and empty scopes", () => {
assert.deepEqual(
pruneSelectedUserSkillSlugsMap(
{
"terminal:1": ["alpha", "missing"],
"workspace:1": ["missing"],
},
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
),
{
"terminal:1": ["alpha"],
},
);
});
test("getNextSelectedUserSkillSlugsMap preserves selections when refresh fails", () => {
const selected = {
"terminal:1": ["alpha", "missing"],
"workspace:1": ["beta"],
};
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, null),
selected,
);
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, { ok: false }),
selected,
);
});

View File

@@ -0,0 +1,73 @@
export interface UserSkillStatusItemLike {
id: string;
slug: string;
name: string;
description: string;
status: "ready" | "warning";
}
export interface UserSkillsStatusLike {
ok: boolean;
skills?: UserSkillStatusItemLike[];
}
export interface UserSkillOption {
id: string;
slug: string;
name: string;
description: string;
}
export function getReadyUserSkillOptions(
status: UserSkillsStatusLike | null | undefined,
): UserSkillOption[] {
if (!status?.ok || !Array.isArray(status.skills)) return [];
return status.skills
.filter((skill) => skill.status === "ready" && typeof skill.slug === "string" && skill.slug.length > 0)
.map((skill) => ({
id: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
}));
}
export function pruneSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
options: UserSkillOption[],
): Record<string, string[]> {
const validSlugs = new Set(options.map((option) => option.slug));
let changed = false;
const nextEntries: Array<[string, string[]]> = [];
for (const [scopeKey, slugs] of Object.entries(selectedByScope)) {
const filteredSlugs = slugs.filter((slug) => validSlugs.has(slug));
if (filteredSlugs.length !== slugs.length) changed = true;
if (filteredSlugs.length > 0) {
nextEntries.push([scopeKey, filteredSlugs]);
} else if (slugs.length > 0) {
changed = true;
}
}
if (!changed) {
return selectedByScope;
}
return Object.fromEntries(nextEntries);
}
export function getNextSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
status: UserSkillsStatusLike | null | undefined,
): Record<string, string[]> {
if (!status?.ok || !Array.isArray(status.skills)) {
return selectedByScope;
}
return pruneSelectedUserSkillSlugsMap(
selectedByScope,
getReadyUserSkillOptions(status),
);
}

View File

@@ -7,7 +7,7 @@
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import { AlertTriangle, Bot, FolderOpen, Globe, Link, Package, RefreshCcw } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
@@ -25,6 +25,7 @@ import {
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Select, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
@@ -32,6 +33,7 @@ import type {
AgentPathInfo,
CodexIntegrationStatus,
CodexLoginSession,
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
@@ -187,6 +189,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
const [userSkillsStatus, setUserSkillsStatus] = useState<UserSkillsStatusResult | null>(null);
const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false);
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
@@ -304,18 +308,14 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [externalAgents, t]);
const hasOpenAiProviderKey = providers.some(
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
);
const refreshCodexIntegration = useCallback(async () => {
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration();
const integration = await bridge.aiCodexGetIntegration(opts);
setCodexIntegration(integration);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
@@ -425,6 +425,54 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}
}, [refreshCodexIntegration]);
const refreshUserSkillsStatus = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsGetStatus) {
setUserSkillsStatus({
ok: false,
error: t('ai.userSkills.unavailable'),
});
return;
}
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsGetStatus();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, [t]);
useEffect(() => {
let cancelled = false;
void refreshUserSkillsStatus().then(() => {
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshUserSkillsStatus]);
const handleOpenUserSkillsFolder = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsOpenFolder) return;
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsOpenFolder();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, []);
return (
<TabsContent
value="ai"
@@ -524,9 +572,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasOpenAiProviderKey={hasOpenAiProviderKey}
error={codexError}
onRefresh={() => void refreshCodexIntegration()}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
@@ -592,7 +639,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<div className="space-y-4">
<div className="flex items-center gap-2">
<Bot size={18} className="text-muted-foreground" />
<Link size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
</div>
@@ -614,6 +661,106 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Package size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.userSkills.title')}</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void refreshUserSkillsStatus()}
disabled={isLoadingUserSkills}
>
<RefreshCcw size={14} className="mr-2" />
{t('ai.userSkills.reload')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleOpenUserSkillsFolder()}
disabled={isLoadingUserSkills}
>
<FolderOpen size={14} className="mr-2" />
{t('ai.userSkills.openFolder')}
</Button>
</div>
</div>
<div className="rounded-lg bg-muted/30 p-4 space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('ai.userSkills.description')}
</p>
{userSkillsStatus?.directoryPath ? (
<p className="text-xs text-muted-foreground">
{t('ai.userSkills.location')}:{" "}
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
</p>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{isLoadingUserSkills
? t('ai.userSkills.loading')
: userSkillsStatus?.ok
? t('ai.userSkills.summary', {
ready: String(userSkillsStatus.readyCount ?? 0),
warnings: String(userSkillsStatus.warningCount ?? 0),
})
: userSkillsStatus?.error || t('ai.userSkills.unavailable')}
</div>
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="space-y-3">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="rounded-md border border-border/60 bg-background/70 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-medium">{skill.name}</div>
<div className="text-sm text-muted-foreground">{skill.description}</div>
<div className="text-xs text-muted-foreground font-mono break-all">
{skill.directoryName}
</div>
</div>
<span
className={
skill.status === "ready"
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
}
>
{skill.status === "ready"
? t('ai.userSkills.status.ready')
: t('ai.userSkills.status.warning')}
</span>
</div>
{skill.warnings.length > 0 ? (
<div className="mt-3 space-y-1 text-sm text-amber-700">
{skill.warnings.map((warning, index) => (
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
</div>
))}
</div>
) : userSkillsStatus?.ok ? (
<div className="text-sm text-muted-foreground">
{t('ai.userSkills.empty')}
</div>
) : null}
</div>
</div>
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}

View File

@@ -7,8 +7,6 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { STORAGE_KEY_SHOW_RECENT_HOSTS } from "../../../infrastructure/config/storageKeys";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
@@ -27,6 +25,12 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
showRecentHosts: boolean;
setShowRecentHosts: (enabled: boolean) => void;
showOnlyUngroupedHostsInRoot: boolean;
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -47,13 +51,14 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
showRecentHosts,
setShowRecentHosts,
showOnlyUngroupedHostsInRoot,
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
} = props;
const [showRecentHosts, setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
@@ -269,6 +274,21 @@ export default function SettingsAppearanceTab(props: {
>
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
</SettingRow>
<SettingRow
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
>
<Toggle
checked={showOnlyUngroupedHostsInRoot}
onChange={setShowOnlyUngroupedHostsInRoot}
/>
</SettingRow>
<SettingRow
label={t('settings.vault.showSftpTab')}
description={t('settings.vault.showSftpTabDesc')}
>
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />

View File

@@ -2,7 +2,9 @@ import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
@@ -25,6 +27,7 @@ export default function SettingsSyncTab(props: {
clearVaultData,
onSettingsApplied,
} = props;
const { t } = useI18n();
const onBuildPayload = useCallback((): SyncPayload => {
// If hook state is empty but localStorage has data, the async store
@@ -54,14 +57,19 @@ export default function SettingsSyncTab(props: {
}, [vault, portForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
});
},
[importDataFromString, importPortForwardingRules, onSettingsApplied],
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildPayload,
applyPayload: () =>
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
}),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
);
const clearAllLocalData = useCallback(() => {

View File

@@ -15,7 +15,6 @@ export const CodexConnectionCard: React.FC<{
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasOpenAiProviderKey: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
@@ -31,7 +30,6 @@ export const CodexConnectionCard: React.FC<{
integration,
loginSession,
isLoading,
hasOpenAiProviderKey,
error,
onRefresh,
onConnect,
@@ -42,6 +40,14 @@ export const CodexConnectionCard: React.FC<{
const { t } = useI18n();
const found = pathInfo?.available;
const customConfigIncomplete = Boolean(
integration?.state === "connected_custom_config"
&& integration.customConfig
&& integration.customConfig.envKey
&& !integration.customConfig.envKeyPresent
&& !integration.customConfig.hasHardcodedApiKey,
);
const status = isResolvingPath
? t('ai.codex.detecting')
: !found
@@ -52,9 +58,13 @@ export const CodexConnectionCard: React.FC<{
? t('ai.codex.connectedChatGPT')
: integration?.state === "connected_api_key"
? t('ai.codex.connectedApiKey')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
: integration?.state === "connected_custom_config"
? customConfigIncomplete
? t('ai.codex.customConfigIncomplete')
: t('ai.codex.connectedCustomConfig')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
const statusClassName = isResolvingPath
? "text-muted-foreground"
@@ -62,9 +72,11 @@ export const CodexConnectionCard: React.FC<{
? "text-amber-500"
: loginSession?.state === "running"
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
: customConfigIncomplete
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
const outputText = loginSession?.error
? loginSession.error
@@ -139,6 +151,9 @@ export const CodexConnectionCard: React.FC<{
{t('common.cancel')}
</Button>
</>
) : integration?.state === "connected_custom_config" ? (
// Nothing to log out of; config.toml is user-owned state.
null
) : integration?.isConnected ? (
<Button variant="outline" size="sm" onClick={onLogout}>
<LogOut size={14} className="mr-1.5" />
@@ -157,10 +172,23 @@ export const CodexConnectionCard: React.FC<{
</Button>
</div>
{hasOpenAiProviderKey && (
<p className="text-xs text-emerald-500">
{t('ai.codex.apiKeyHint')}
</p>
{integration?.state === "connected_custom_config" && integration.customConfig && (
<>
<p className="text-xs text-emerald-500">
{t('ai.codex.customConfigHint').replace(
'{provider}',
integration.customConfig.displayName || integration.customConfig.providerName,
)}
</p>
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
<p className="text-xs text-amber-500">
{t('ai.codex.customConfigMissingEnvKey').replace(
'{envKey}',
integration.customConfig.envKey,
)}
</p>
)}
</>
)}
</>
)}

View File

@@ -10,14 +10,27 @@ import type {
export type CodexIntegrationState =
| "connected_chatgpt"
| "connected_api_key"
| "connected_custom_config"
| "not_logged_in"
| "unknown";
export interface CodexCustomProviderConfig {
providerName: string;
displayName: string;
baseUrl: string | null;
envKey: string | null;
envKeyPresent: boolean;
hasHardcodedApiKey: boolean;
model: string | null;
authHash: string | null;
}
export interface CodexIntegrationStatus {
state: CodexIntegrationState;
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: CodexCustomProviderConfig | null;
}
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
@@ -37,6 +50,28 @@ export interface AgentPathInfo {
available: boolean;
}
export interface UserSkillStatusItem {
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: "ready" | "warning";
warnings: string[];
}
export interface UserSkillsStatusResult {
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: UserSkillStatusItem[];
warnings?: string[];
error?: string;
}
export interface ProviderFormState {
name: string;
apiKey: string;
@@ -57,12 +92,14 @@ export interface FetchBridge {
}
export interface NetcattyAiBridge {
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
openExternal?: (url: string) => Promise<void>;
}

View File

@@ -318,6 +318,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
<div
key={tab.id}
data-tab-id={tab.id}
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
onClick={(e) => handleSelectTabClick(e, tab.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
@@ -325,7 +327,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out",
isActive
? "text-foreground border-b-2"

View File

@@ -46,6 +46,8 @@ interface UseSftpViewPaneCallbacksParams {
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
}
export const useSftpViewPaneCallbacks = ({

View File

@@ -31,6 +31,7 @@ export interface TerminalContextMenuProps {
isAlternateScreen?: boolean;
onCopy?: () => void;
onPaste?: () => void;
onPasteSelection?: () => void;
onSelectAll?: () => void;
onClear?: () => void;
onSplitHorizontal?: () => void;
@@ -48,6 +49,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
isAlternateScreen = false,
onCopy,
onPaste,
onPasteSelection,
onSelectAll,
onClear,
onSplitHorizontal,
@@ -70,6 +72,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const copyShortcut = getShortcut('copy');
const pasteShortcut = getShortcut('paste');
const pasteSelectionShortcut = getShortcut('paste-selection');
const selectAllShortcut = getShortcut('select-all');
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
@@ -123,6 +126,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onPasteSelection && (
<ContextMenuItem onClick={onPasteSelection} disabled={!hasSelection}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.pasteSelection')}
<ContextMenuShortcut>{pasteSelectionShortcut}</ContextMenuShortcut>
</ContextMenuItem>
)}
<ContextMenuItem onClick={onSelectAll}>
<TerminalIcon size={14} className="mr-2" />
{t('terminal.menu.selectAll')}

View File

@@ -56,6 +56,24 @@ export const useTerminalContextActions = ({
}
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
const onPasteSelection = useCallback(() => {
const term = termRef.current;
if (!term) return;
const selection = term.getSelection();
if (!selection || !sessionRef.current) return;
let data = normalizeLineEndings(selection);
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
terminalBackend.writeToSession(sessionRef.current, data);
if (scrollOnPasteRef?.current) {
term.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
term.scrollToBottom();
});
}
}
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;
if (!term) return;
@@ -76,5 +94,5 @@ export const useTerminalContextActions = ({
onHasSelectionChange?.(true);
}, [onHasSelectionChange, termRef]);
return { onCopy, onPaste, onSelectAll, onClear, onSelectWord };
return { onCopy, onPaste, onPasteSelection, onSelectAll, onClear, onSelectWord };
};

View File

@@ -328,12 +328,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startSSH = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.backendAvailable()) {
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
term.writeln(
@@ -717,12 +711,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startTelnet = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]");
@@ -756,12 +744,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startMosh = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]");
@@ -812,12 +794,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const startLocal = async (term: XTerm) => {
try {
term.clear?.();
} catch (err) {
logger.warn("Failed to clear terminal before connect", err);
}
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
term.writeln(

View File

@@ -182,7 +182,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
const scrollback = settings?.scrollback ?? 10000;
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
// "no limit", so map it to a large value instead.
const rawScrollback = settings?.scrollback ?? 10000;
const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;
@@ -421,12 +425,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (!consumed) return false; // Event was consumed by autocomplete
}
if ((e.ctrlKey || e.metaKey) && e.key === "f" && e.type === "keydown") {
e.preventDefault();
ctx.setIsSearchOpen(true);
return false;
}
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
@@ -499,6 +497,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
break;
}
case "pasteSelection": {
const selection = term.getSelection();
const id = ctx.sessionRef.current;
if (selection && id) {
let data = normalizeLineEndings(selection);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
break;
}
case "selectAll": {
term.selectAll();
break;

View File

@@ -394,9 +394,10 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
// Terminal Operations
{ id: 'copy', action: 'copy', label: 'Copy from Terminal', mac: '⌘ + C', pc: 'Ctrl + Shift + C', category: 'terminal' },
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
{ id: 'paste-selection', action: 'pasteSelection', label: 'Paste Selection to Terminal', mac: '⌘ + Shift + X', pc: 'Ctrl + Shift + X', category: 'terminal' },
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + Shift + F', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + F', category: 'terminal' },
// Navigation / Split View
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },

View File

@@ -206,6 +206,10 @@ export interface SyncPayload {
immersiveMode?: boolean;
// Vault: show recently connected hosts
showRecentHosts?: boolean;
// Vault: root list shows only ungrouped hosts
showOnlyUngroupedHostsInRoot?: boolean;
// Top tabs: show standalone SFTP view tab
showSftpTab?: boolean;
};
// Sync metadata

View File

@@ -8,7 +8,8 @@
const { execFileSync } = require("node:child_process");
const { createHash } = require("node:crypto");
const { existsSync } = require("node:fs");
const { existsSync, readFileSync } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { stripAnsi, extractFirstNonLocalhostUrl, toUnpackedAsarPath } = require("./shellUtils.cjs");
@@ -124,6 +125,212 @@ function getActiveCodexLoginSession() {
return null;
}
// ── Codex config.toml probing ──
//
// Users who hand-configure `~/.codex/config.toml` with a custom
// `model_provider` + matching `[model_providers.<name>]` entry are fully
// functional from the Codex CLI, but `codex login status` doesn't see them
// because it only reports on `~/.codex/auth.json` (populated by `codex login`).
// We read and minimally parse the config file so we can surface this as a
// valid "ready" state and skip the ChatGPT login prompt in the UI.
/** Find `#` outside quoted regions. Tracks escape state via a flag rather
* than peeking at the previous character, so even runs of backslashes like
* `"C:\\path\\"` close the string correctly. Literal (single-quoted) TOML
* strings don't recognize `\` as an escape, so only honor escapes inside
* basic (double-quoted) strings. */
function findUnquotedHash(value) {
let inStr = false;
let quote = "";
let escaped = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (inStr) {
if (escaped) {
escaped = false;
continue;
}
if (quote === '"' && ch === "\\") {
escaped = true;
continue;
}
if (ch === quote) {
inStr = false;
quote = "";
}
continue;
}
if (ch === '"' || ch === "'") {
inStr = true;
quote = ch;
continue;
}
if (ch === "#") return i;
}
return -1;
}
/**
* Parse the narrow subset of TOML we need from Codex's config.toml:
* - top-level string keys (e.g. `model_provider = "my_provider"`)
* - `[model_providers.<name>]` tables with string-valued keys
* Unsupported TOML features (arrays, inline tables, multi-line strings, etc.)
* are ignored — Codex's config.toml doesn't use them for provider definitions.
*/
function parseCodexConfigToml(text) {
const result = { model_providers: {} };
let currentProvider = null;
let atTopLevel = true;
// Strip UTF-8 BOM so the first key still matches the regex on Windows-edited files.
const normalized = String(text || "").replace(/^\uFEFF/, "");
const lines = normalized.split(/\r?\n/);
for (const rawLine of lines) {
let line = rawLine;
const hashIdx = findUnquotedHash(line);
if (hashIdx >= 0) line = line.slice(0, hashIdx);
line = line.trim();
if (!line) continue;
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
const section = sectionMatch[1].trim();
if (section.startsWith("model_providers.")) {
currentProvider = section.slice("model_providers.".length);
if (!result.model_providers[currentProvider]) {
result.model_providers[currentProvider] = {};
}
atTopLevel = false;
} else {
currentProvider = null;
atTopLevel = false;
}
continue;
}
const kvMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*(.+)$/);
if (!kvMatch) continue;
const key = kvMatch[1];
let raw = kvMatch[2].trim();
let value;
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
value = raw.slice(1, -1);
} else {
value = raw;
}
if (atTopLevel) {
result[key] = value;
} else if (currentProvider) {
result.model_providers[currentProvider][key] = value;
}
}
return result;
}
/**
* Inspect `~/.codex/config.toml` to determine whether the user has
* configured a custom `model_provider` that isn't the built-in OpenAI/ChatGPT
* path.
*
* Returns null when:
* - the config file doesn't exist or can't be read
* - no `model_provider` is set, or it points to the default `openai` preset
* - the referenced provider entry is missing (config is malformed)
*
* Returns a summary object otherwise — even if the env_key isn't currently
* exported in the shell environment. That case is surfaced via
* `envKeyPresent: false` so the UI can warn the user; we don't want the
* absence of an env var to silently fall back to the ChatGPT login flow,
* because the config.toml is a strong signal the user doesn't want that.
*/
function readCodexCustomProviderConfig(shellEnv) {
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
if (!home) return null;
const configPath = path.join(home, ".codex", "config.toml");
if (!existsSync(configPath)) return null;
let text;
try {
text = readFileSync(configPath, "utf8");
} catch {
return null;
}
let parsed;
try {
parsed = parseCodexConfigToml(text);
} catch {
return null;
}
const activeName = typeof parsed.model_provider === "string"
? parsed.model_provider.trim()
: "";
if (!activeName) return null;
// The built-in "openai" provider still goes through ChatGPT/API-key auth
// managed by `codex login`, so treating it as "custom" would be wrong.
if (activeName === "openai") return null;
const providerEntry = parsed.model_providers?.[activeName];
if (!providerEntry) return null;
const envKeyName = typeof providerEntry.env_key === "string" ? providerEntry.env_key.trim() : "";
const envKeyValue = envKeyName && shellEnv ? String(shellEnv[envKeyName] || "").trim() : "";
const hardcodedApiKey = typeof providerEntry.api_key === "string" ? providerEntry.api_key.trim() : "";
const activeModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
// Hash the actual auth material (either the hardcoded api_key or the
// resolved env_key value) so the ACP provider fingerprint changes when
// the user rotates their key — without ever returning the raw value
// across the IPC boundary.
const authMaterial = hardcodedApiKey || envKeyValue;
const authHash = authMaterial
? createHash("sha256").update(authMaterial).digest("hex")
: null;
return {
providerName: activeName,
displayName: providerEntry.name || activeName,
baseUrl: providerEntry.base_url || null,
envKey: envKeyName || null,
envKeyPresent: Boolean(envKeyValue),
hasHardcodedApiKey: Boolean(hardcodedApiKey),
model: activeModel || null,
authHash,
};
}
/**
* Returns a user-facing error message when a Codex config.toml custom
* provider references an env_key that isn't exported in the shell env and
* doesn't have a hardcoded api_key either — otherwise returns null. Shared
* by every spawn path (stream handler, list-models handler) so users get
* the same actionable message regardless of which one hits first.
*/
function getCodexCustomConfigPreflightError(customConfig) {
if (!customConfig) return null;
if (!customConfig.envKey) return null;
if (customConfig.envKeyPresent || customConfig.hasHardcodedApiKey) return null;
return `Codex is configured to use the "${customConfig.displayName}" provider from ~/.codex/config.toml, but the environment variable ${customConfig.envKey} is not set. Export it in your shell (e.g. add to ~/.zshrc) and click "Refresh Status" in Settings.`;
}
/**
* Compute the ACP auth override object for Codex spawn sites.
* - netcatty-managed API key present → "codex-api-key"
* - user's own ~/.codex/config.toml custom provider detected → no override
* (so codex-acp resolves auth from the shell env / config itself)
* - otherwise → "chatgpt" (triggers the browser OAuth login flow)
*
* Returned as an object designed to be spread into createACPProvider options.
*/
function getCodexAuthOverride(apiKey, shellEnv) {
if (apiKey) return { authMethodId: "codex-api-key" };
if (readCodexCustomProviderConfig(shellEnv)) return {};
return { authMethodId: "chatgpt" };
}
// ── Integration state ──
function normalizeCodexIntegrationState(rawOutput) {
@@ -199,6 +406,9 @@ module.exports = {
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
getCodexAuthOverride,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,

View File

@@ -211,6 +211,15 @@ async function getShellEnv() {
return _cachedShellEnv;
}
/**
* Drop the shell-env cache so the next getShellEnv() call re-spawns the
* login shell. Useful when the user has just exported a new variable in
* their rc file and clicks "Refresh Status" without restarting the app.
*/
function invalidateShellEnvCache() {
_cachedShellEnv = null;
}
// ── Claude Code ACP binary resolution ──
/**
@@ -316,5 +325,6 @@ module.exports = {
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,
};

View File

@@ -0,0 +1,511 @@
const fsPromises = require("node:fs/promises");
const path = require("node:path");
const USER_SKILLS_DIR_NAME = "Skills";
const USER_SKILLS_README_NAME = "README.txt";
const MAX_SKILL_BYTES = 24 * 1024;
const MAX_DESCRIPTION_LENGTH = 280;
const MAX_INDEX_SKILLS = 8;
const MAX_EXPLICIT_SKILLS = 4;
const MAX_MATCHED_SKILLS = 2;
const MAX_MATCHED_SKILL_CHARS = 6000;
const MAX_TOTAL_INJECTED_SKILL_CHARS = 12000;
const USER_SKILLS_README_CONTENT = [
"Netcatty user skills",
"",
"Add one folder per skill inside this directory.",
"Each skill folder must contain a SKILL.md file.",
"",
"Example layout:",
" Skills/",
" My Skill/",
" SKILL.md",
"",
"Minimal SKILL.md:",
" ---",
" name: My Skill",
" description: Short summary of what this skill helps with.",
" ---",
"",
" Write the skill instructions here.",
"",
"After adding or editing a skill, reopen the AI settings page or start a new chat to refresh the list.",
"",
].join("\n");
const STOPWORDS = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "when", "then",
"only", "your", "will", "should", "have", "has", "had", "using", "use",
"agent", "skill", "skills", "task", "file", "files", "user", "into", "about",
]);
function stripQuotes(value) {
const trimmed = String(value || "").trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function slugifySkill(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function tokenize(value) {
return String(value || "")
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !STOPWORDS.has(token));
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatSkillReadWarning(error) {
const code = typeof error?.code === "string" ? error.code : null;
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
return code
? `Failed to read SKILL.md (${code}: ${message}).`
: `Failed to read SKILL.md (${message}).`;
}
function containsPlaintextPhrase(prompt, phrase) {
const trimmedPhrase = String(phrase || "").trim();
if (!trimmedPhrase) return false;
const pattern = new RegExp(`(^|\\s)${escapeRegExp(trimmedPhrase)}(?=$|\\s|[.,!?;:])`, "i");
return pattern.test(String(prompt || ""));
}
function parseFrontmatter(content) {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
if (!match) {
return { attributes: {}, body: content, hasFrontmatter: false };
}
const attributes = {};
for (const rawLine of match[1].split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const colonIndex = line.indexOf(":");
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim();
const value = stripQuotes(line.slice(colonIndex + 1).trim());
if (key) attributes[key] = value;
}
return {
attributes,
body: content.slice(match[0].length),
hasFrontmatter: true,
};
}
function summarizeSkillSlugs(skillsOrSlugs, maxItems = 4) {
const values = (Array.isArray(skillsOrSlugs) ? skillsOrSlugs : [])
.map((entry) => {
if (typeof entry === "string") return entry;
const slug = typeof entry?.slug === "string" ? entry.slug : "";
return slug;
})
.filter(Boolean)
.map((slug) => `/${slug}`);
if (values.length <= maxItems) {
return values.join(", ");
}
return `${values.slice(0, maxItems).join(", ")}, and ${values.length - maxItems} more`;
}
function getUserSkillsDir(electronApp) {
const userDataDir = electronApp?.getPath?.("userData");
if (!userDataDir) {
throw new Error("Electron app userData path is unavailable.");
}
return path.join(userDataDir, USER_SKILLS_DIR_NAME);
}
async function ensureUserSkillsDir(electronApp) {
const skillsDir = getUserSkillsDir(electronApp);
await fsPromises.mkdir(skillsDir, { recursive: true });
return skillsDir;
}
async function ensureUserSkillsReadme(electronApp) {
const skillsDir = await ensureUserSkillsDir(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir);
if (dirEntries.length === 0) {
await fsPromises.writeFile(
path.join(skillsDir, USER_SKILLS_README_NAME),
USER_SKILLS_README_CONTENT,
"utf8",
);
}
return skillsDir;
}
async function scanUserSkills(electronApp) {
const skillsDir = await ensureUserSkillsReadme(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir, { withFileTypes: true });
const skills = [];
const warnings = [];
for (const entry of dirEntries) {
// Only process actual directories, skipping symlinks for security
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
const dirName = entry.name;
// Basic path traversal protection: skip any directory name containing path separators
if (dirName.includes("/") || dirName.includes("\\") || dirName === ".." || dirName === ".") {
continue;
}
const skillDir = path.join(skillsDir, dirName);
const skillPath = path.join(skillDir, "SKILL.md");
const baseItem = {
id: dirName,
slug: slugifySkill(dirName),
directoryName: dirName,
directoryPath: skillDir,
skillPath,
name: dirName,
description: "",
status: "warning",
warnings: [],
};
try {
await fsPromises.access(skillPath);
} catch {
baseItem.warnings.push("Missing SKILL.md");
warnings.push(`${dirName}: Missing SKILL.md`);
skills.push(baseItem);
continue;
}
try {
const stat = await fsPromises.lstat(skillPath);
if (stat.isSymbolicLink()) {
baseItem.warnings.push("SKILL.md must not be a symbolic link.");
warnings.push(`${dirName}: SKILL.md must not be a symbolic link.`);
skills.push(baseItem);
continue;
}
if (!stat.isFile()) {
baseItem.warnings.push("SKILL.md must be a regular file.");
warnings.push(`${dirName}: SKILL.md must be a regular file.`);
skills.push(baseItem);
continue;
}
if (stat.size > MAX_SKILL_BYTES) {
baseItem.warnings.push(`SKILL.md is too large (${stat.size} bytes > ${MAX_SKILL_BYTES} bytes).`);
warnings.push(`${dirName}: SKILL.md is too large.`);
skills.push(baseItem);
continue;
}
const content = await fsPromises.readFile(skillPath, "utf8");
const { attributes, body, hasFrontmatter } = parseFrontmatter(content);
const name = stripQuotes(attributes.name || "").trim();
const description = stripQuotes(attributes.description || "").trim();
const usableSlug = slugifySkill(name || dirName);
if (!hasFrontmatter) {
baseItem.warnings.push("Missing YAML frontmatter.");
}
if (!name) {
baseItem.warnings.push("Missing frontmatter field: name.");
}
if (!description) {
baseItem.warnings.push("Missing frontmatter field: description.");
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
baseItem.warnings.push(`Description is too long (${description.length} chars > ${MAX_DESCRIPTION_LENGTH}).`);
}
if (!usableSlug) {
baseItem.warnings.push("Skill name must include ASCII letters or digits to generate a usable slug.");
}
if (baseItem.warnings.length > 0) {
warnings.push(...baseItem.warnings.map((warning) => `${dirName}: ${warning}`));
skills.push({
...baseItem,
slug: usableSlug,
name: name || dirName,
description,
});
continue;
}
skills.push({
...baseItem,
slug: usableSlug,
name,
description,
status: "ready",
warnings: [],
body,
mtimeMs: stat.mtimeMs,
});
} catch (error) {
const warning = formatSkillReadWarning(error);
baseItem.warnings.push(warning);
warnings.push(`${dirName}: ${warning}`);
skills.push(baseItem);
}
}
const readySkillsBySlug = new Map();
for (const skill of skills) {
if (skill.status !== "ready" || !skill.slug) continue;
const matches = readySkillsBySlug.get(skill.slug);
if (matches) {
matches.push(skill);
} else {
readySkillsBySlug.set(skill.slug, [skill]);
}
}
for (const [slug, duplicateSkills] of readySkillsBySlug.entries()) {
if (duplicateSkills.length < 2) continue;
const duplicateWarning = `Duplicate skill slug "${slug}". Rename the skill or change its frontmatter name.`;
for (const skill of duplicateSkills) {
skill.status = "warning";
skill.warnings = [...skill.warnings, duplicateWarning];
warnings.push(`${skill.directoryName}: ${duplicateWarning}`);
}
}
const readyCount = skills.filter((skill) => skill.status === "ready").length;
const warningCount = skills.filter((skill) => skill.status === "warning").length;
return {
directoryPath: skillsDir,
readyCount,
warningCount,
skills: skills.map((skill) => ({
id: skill.id,
slug: skill.slug,
directoryName: skill.directoryName,
directoryPath: skill.directoryPath,
skillPath: skill.skillPath,
name: skill.name,
description: skill.description,
status: skill.status,
warnings: skill.warnings,
})),
warnings,
_readySkills: skills.filter((skill) => skill.status === "ready"),
};
}
/**
* Scores how well a skill matches a user prompt.
*
* Scored based on:
* - 50 points: Plain-text name/directory mention (e.g. prompt contains "my skill")
* - 1 point per keyword overlap (after tokenization/stopword filtering)
*
* @param {string} prompt - The user prompt
* @param {object} skill - The skill object from scanUserSkills
* @returns {number} The score (higher is better)
*/
function scoreSkillMatch(prompt, skill) {
const name = String(skill.name || "").trim();
const directoryName = String(skill.directoryName || "").trim();
// High weight for an exact plain-text mention of the skill name.
if (
(name && containsPlaintextPhrase(prompt, name)) ||
(directoryName && containsPlaintextPhrase(prompt, directoryName))
) {
return 50;
}
// Fallback to token keyword overlap
const promptTokens = new Set(tokenize(prompt));
const skillTokens = tokenize(`${skill.name} ${skill.description}`);
let overlap = 0;
for (const token of skillTokens) {
if (promptTokens.has(token)) overlap += 1;
}
return overlap;
}
/**
* Builds the contextual prompt part from matched user skills.
*
* @param {object} electronApp - The Electron app instance
* @param {string} prompt - The user's input prompt
* @param {string[]} selectedSkillSlugs - Explicitly requested skill slugs
* @returns {Promise<{context: string, status: object}>} The built prompt part and scan status
*/
async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs = []) {
const status = await scanUserSkills(electronApp);
const readySkills = status._readySkills || [];
const trimmedPrompt = String(prompt || "").trim();
if (readySkills.length === 0) {
return { context: "", status };
}
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
const remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
const indexLine = indexSkills
.map((skill) => `${skill.name}: ${skill.description}`)
.join("; ");
const orderedExplicitSlugs = [];
const seenExplicitSlugs = new Set();
for (const rawSlug of Array.isArray(selectedSkillSlugs) ? selectedSkillSlugs : []) {
const slug = slugifySkill(rawSlug);
if (!slug || seenExplicitSlugs.has(slug)) continue;
seenExplicitSlugs.add(slug);
orderedExplicitSlugs.push(slug);
}
const additionalExplicitCount = Math.max(orderedExplicitSlugs.length - MAX_EXPLICIT_SKILLS, 0);
const cappedExplicitSlugs = orderedExplicitSlugs.slice(0, MAX_EXPLICIT_SKILLS);
const explicitSlugSet = new Set(cappedExplicitSlugs);
const readySkillsBySlug = new Map(readySkills.map((skill) => [skill.slug, skill]));
const explicitSkills = [];
const unavailableExplicitSlugs = [];
for (const slug of cappedExplicitSlugs) {
const skill = readySkillsBySlug.get(slug);
if (skill) {
explicitSkills.push(skill);
} else {
unavailableExplicitSlugs.push(slug);
}
}
const matchedSkills = readySkills
.filter((skill) => !explicitSlugSet.has(skill.slug))
.map((skill) => ({ skill, score: scoreSkillMatch(trimmedPrompt, skill) }))
.filter((entry) => entry.score >= 2)
.sort((left, right) => right.score - left.score)
.slice(0, MAX_MATCHED_SKILLS)
.map((entry) => entry.skill);
const finalSkills = [...explicitSkills, ...matchedSkills];
const parts = [
"User-managed skills are installed in Netcatty.",
`Available user skills: ${indexLine}${remainingCount > 0 ? `; and ${remainingCount} more.` : "."}`,
"Use a user-managed skill only when it clearly matches the current request.",
];
if (additionalExplicitCount > 0) {
parts.push(
`The user selected ${additionalExplicitCount} additional Netcatty user skills that were omitted to stay within the prompt budget.`,
);
}
if (unavailableExplicitSlugs.length > 0) {
parts.push(
`The user explicitly selected these Netcatty user skills for this request, but their content is currently unavailable: ${summarizeSkillSlugs(unavailableExplicitSlugs)}.`,
);
}
if (finalSkills.length > 0) {
const includedSkillSections = [];
const omittedSkills = [];
const truncatedSkills = [];
let remainingSkillChars = MAX_TOTAL_INJECTED_SKILL_CHARS;
let budgetStopIndex = finalSkills.length;
for (let index = 0; index < finalSkills.length; index += 1) {
const skill = finalSkills[index];
const heading = `### ${skill.name}\n`;
const maxBodyChars = Math.min(
MAX_MATCHED_SKILL_CHARS,
Math.max(remainingSkillChars - heading.length, 0),
);
if (maxBodyChars <= 0) {
omittedSkills.push(skill);
continue;
}
const rawBody = String(skill.body || "").trim();
if (!rawBody) {
omittedSkills.push(skill);
continue;
}
if (rawBody.length > maxBodyChars && includedSkillSections.length > 0) {
omittedSkills.push(skill);
budgetStopIndex = index;
continue;
}
const body = rawBody.slice(0, maxBodyChars);
if (!body) {
omittedSkills.push(skill);
continue;
}
includedSkillSections.push(`${heading}${body}`);
remainingSkillChars -= heading.length + body.length;
if (body.length < rawBody.length) {
truncatedSkills.push(skill);
budgetStopIndex = index + 1;
break;
}
}
parts.push("Matched user-managed skills for this request:");
if (includedSkillSections.length > 0) {
parts.push(...includedSkillSections);
}
const omittedAfterIncluded = finalSkills.slice(budgetStopIndex);
for (const skill of omittedAfterIncluded) {
if (!omittedSkills.includes(skill) && !truncatedSkills.includes(skill)) {
omittedSkills.push(skill);
}
}
if (truncatedSkills.length > 0) {
parts.push(
`Some matched user-managed skill content was truncated to stay within the prompt budget: ${summarizeSkillSlugs(truncatedSkills)}.`,
);
}
if (omittedSkills.length > 0) {
parts.push(
`Additional matched user-managed skills were omitted to stay within the prompt budget: ${summarizeSkillSlugs(omittedSkills)}.`,
);
}
}
return {
context: parts.join("\n\n"),
status,
};
}
function toPublicUserSkillsStatus(status) {
if (!status || typeof status !== "object") {
return status;
}
const publicStatus = { ...status };
delete publicStatus._readySkills;
return publicStatus;
}
module.exports = {
USER_SKILLS_DIR_NAME,
getUserSkillsDir,
ensureUserSkillsDir,
ensureUserSkillsReadme,
scanUserSkills,
buildUserSkillsContext,
toPublicUserSkillsStatus,
};

View File

@@ -0,0 +1,336 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { buildUserSkillsContext, scanUserSkills } = require("./userSkills.cjs");
async function withUserSkills(skillDefinitions, run) {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-user-skills-"));
const userDataDir = path.join(rootDir, "userData");
const skillsDir = path.join(userDataDir, "Skills");
await fs.mkdir(skillsDir, { recursive: true });
for (const skill of skillDefinitions) {
const skillDir = path.join(skillsDir, skill.directoryName);
await fs.mkdir(skillDir, { recursive: true });
const content = [
"---",
`name: ${skill.name}`,
`description: ${skill.description}`,
"---",
"",
skill.body,
"",
].join("\n");
await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf8");
}
const electronApp = {
getPath(key) {
return key === "userData" ? userDataDir : "";
},
};
try {
await run(electronApp);
} finally {
await fs.rm(rootDir, { recursive: true, force: true });
}
}
test("does not auto-match a user skill from an absolute path segment", async () => {
await withUserSkills(
[
{
directoryName: "Tmp Helper",
name: "tmp",
description: "Helper for scratch space workflows.",
body: "Body for tmp",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"please inspect /tmp/netcatty.log",
[],
);
assert.equal(result.context.includes("Matched user-managed skills for this request:"), false);
assert.equal(result.context.includes("Body for tmp"), false);
},
);
});
test("keeps every explicitly selected skill in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Alpha One",
name: "Alpha One",
description: "Alpha helper.",
body: "Body for Alpha One",
},
{
directoryName: "Beta Two",
name: "Beta Two",
description: "Beta helper.",
body: "Body for Beta Two",
},
{
directoryName: "Gamma Three",
name: "Gamma Three",
description: "Gamma helper.",
body: "Body for Gamma Three",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["alpha-one", "beta-two", "gamma-three"],
);
assert.equal(result.context.includes("Body for Alpha One"), true);
assert.equal(result.context.includes("Body for Beta Two"), true);
assert.equal(result.context.includes("Body for Gamma Three"), true);
},
);
});
test("preserves an unavailable explicit selection in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Beta",
name: "Beta",
description: "Beta helper.",
body: "Body for Beta",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["missing-skill"],
);
assert.equal(result.context.includes("Available user skills: Beta: Beta helper."), true);
assert.equal(result.context.includes("/missing-skill"), true);
assert.match(result.context, /explicitly selected/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("initializing an empty skills directory creates only an instructions file", async () => {
await withUserSkills([], async (electronApp) => {
const status = await scanUserSkills(electronApp);
const entries = await fs.readdir(status.directoryPath);
assert.deepEqual(status.skills, []);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 0);
assert.deepEqual(entries.sort(), ["README.txt"]);
});
});
test("unreadable SKILL.md becomes a warning instead of aborting the entire scan", async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
{
directoryName: "Broken Skill",
name: "Broken Skill",
description: "This file will be unreadable.",
body: "Broken body",
},
],
async (electronApp) => {
const unreadablePath = path.join(
electronApp.getPath("userData"),
"Skills",
"Broken Skill",
"SKILL.md",
);
await fs.chmod(unreadablePath, 0o000);
try {
const status = await scanUserSkills(electronApp);
const workingSkill = status.skills.find((skill) => skill.name === "Working Skill");
const brokenSkill = status.skills.find((skill) => skill.directoryName === "Broken Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(workingSkill?.status, "ready");
assert.equal(brokenSkill?.status, "warning");
assert.match(brokenSkill?.warnings?.[0] || "", /Failed to read SKILL\.md/i);
} finally {
await fs.chmod(unreadablePath, 0o644);
}
},
);
});
test("symlinked SKILL.md is downgraded to a warning and never injected", async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
],
async (electronApp) => {
const skillsDir = path.join(electronApp.getPath("userData"), "Skills");
const linkedDir = path.join(skillsDir, "Linked Skill");
const externalTarget = path.join(skillsDir, "..", "outside-secret.md");
await fs.mkdir(linkedDir, { recursive: true });
await fs.writeFile(
externalTarget,
[
"---",
"name: Linked Skill",
"description: Linked helper.",
"---",
"",
"TOPSECRET",
"",
].join("\n"),
"utf8",
);
await fs.symlink(externalTarget, path.join(linkedDir, "SKILL.md"));
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["linked-skill"]);
const linkedSkill = status.skills.find((skill) => skill.directoryName === "Linked Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(linkedSkill?.status, "warning");
assert.match(linkedSkill?.warnings?.[0] || "", /symbolic link/i);
assert.equal(result.context.includes("TOPSECRET"), false);
assert.match(result.context, /linked-skill/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("duplicate normalized slugs are downgraded to warnings and not injected explicitly", async () => {
await withUserSkills(
[
{
directoryName: "Foo Bar",
name: "Foo Bar",
description: "First skill.",
body: "Body for Foo Bar",
},
{
directoryName: "foo-bar",
name: "foo-bar",
description: "Second skill.",
body: "Body for foo-bar",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["foo-bar"]);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 2);
assert.equal(status.skills.every((skill) => skill.status === "warning"), true);
assert.equal(
status.skills.every((skill) =>
skill.warnings.some((warning) => warning.includes('Duplicate skill slug "foo-bar"')),
),
true,
);
assert.equal(result.context.includes("Body for Foo Bar"), false);
assert.equal(result.context.includes("Body for foo-bar"), false);
},
);
});
test("skills without a usable ASCII slug are downgraded to warnings", async () => {
await withUserSkills(
[
{
directoryName: "部署助手",
name: "部署助手",
description: "Deployment helper.",
body: "Body for 部署助手",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 1);
assert.equal(status.skills[0]?.status, "warning");
assert.equal(status.skills[0]?.slug, "");
assert.match(
status.skills[0]?.warnings?.[0] || "",
/usable slug/i,
);
},
);
});
test("explicit selections are capped to stay within the prompt budget", async () => {
await withUserSkills(
[
{
directoryName: "Skill One",
name: "Skill One",
description: "Helper one.",
body: "BODY_ONE_" + "a".repeat(3500),
},
{
directoryName: "Skill Two",
name: "Skill Two",
description: "Helper two.",
body: "BODY_TWO_" + "b".repeat(3500),
},
{
directoryName: "Skill Three",
name: "Skill Three",
description: "Helper three.",
body: "BODY_THREE_" + "c".repeat(3500),
},
{
directoryName: "Skill Four",
name: "Skill Four",
description: "Helper four.",
body: "BODY_FOUR_" + "d".repeat(3500),
},
{
directoryName: "Skill Five",
name: "Skill Five",
description: "Helper five.",
body: "BODY_FIVE_" + "e".repeat(3500),
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["skill-one", "skill-two", "skill-three", "skill-four", "skill-five"],
);
assert.equal(result.context.includes("BODY_ONE_"), true);
assert.equal(result.context.includes("BODY_TWO_"), true);
assert.equal(result.context.includes("BODY_THREE_"), true);
assert.equal(result.context.includes("BODY_FOUR_"), false);
assert.equal(result.context.includes("BODY_FIVE_"), false);
assert.match(result.context, /prompt budget|additional selected/i);
},
);
});

View File

@@ -15,6 +15,11 @@ const { existsSync } = fs;
const mcpServerBridge = require("./mcpServerBridge.cjs");
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
const {
scanUserSkills,
buildUserSkillsContext,
toPublicUserSkillsStatus,
} = require("./ai/userSkills.cjs");
// ── Extracted modules ──
const {
@@ -24,6 +29,7 @@ const {
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
getShellEnv,
invalidateShellEnvCache,
serializeStreamChunk,
toUnpackedAsarPath,
} = require("./ai/shellUtils.cjs");
@@ -35,6 +41,9 @@ const {
toCodexLoginSessionResponse,
getActiveCodexLoginSession,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
getCodexAuthOverride,
getCodexCustomConfigPreflightError,
extractCodexError,
isCodexAuthError,
getCodexAuthFingerprint,
@@ -95,7 +104,8 @@ function getSkillsCliInvocation() {
};
}
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession }) {
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession, userSkillsContext }) {
const userSkillsPreamble = userSkillsContext ? `${userSkillsContext}\n\n` : "";
if (mode === "skills") {
const { commandPrefix: cliCommandPrefix, launcherPath, usesLauncher } = getSkillsCliInvocation();
const skillHint = existsSync(NETCATTY_TOOL_SKILL_PATH)
@@ -133,6 +143,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
: `Start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `;
return (
`${userSkillsPreamble}` +
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
`${skillHint}` +
`${cliHint}` +
@@ -161,6 +172,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
}
return (
`${userSkillsPreamble}` +
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
@@ -234,6 +246,34 @@ function resolveProviderApiKey(providerId) {
};
}
function getAcpProviderAuthFingerprint(apiKey, provider, customConfig) {
const parts = [
typeof apiKey === "string" ? apiKey.trim() : "",
typeof provider?.id === "string" ? provider.id.trim() : "",
typeof provider?.providerId === "string" ? provider.providerId.trim() : "",
typeof provider?.baseURL === "string" ? provider.baseURL.trim() : "",
customConfig
? [
"custom",
customConfig.providerName || "",
customConfig.baseUrl || "",
customConfig.envKey || "",
customConfig.envKeyPresent ? "1" : "0",
// authHash changes when the user rotates their hardcoded api_key
// or the env_key's resolved value; without it a cached ACP
// provider would keep serving the stale key.
customConfig.authHash || "",
].join(":")
: "",
].filter(Boolean);
if (parts.length === 0) {
return null;
}
return getCodexAuthFingerprint(parts.join("\n"));
}
/** Check if TLS verification should be skipped for a given provider. */
function shouldSkipTLSVerify(providerId) {
if (!providerId) return false;
@@ -734,6 +774,41 @@ function streamRequest(url, options, event, requestId, skipTLS) {
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:user-skills:status", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
return { ok: true, ...toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:open", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
const openResult = await electronModule?.shell?.openPath?.(status.directoryPath);
return {
ok: !openResult,
error: openResult || undefined,
...toPublicUserSkillsStatus(status),
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:build-context", async (event, { prompt, selectedSkillSlugs }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const { context, status } = await buildUserSkillsContext(electronModule?.app, prompt, selectedSkillSlugs);
return { ok: true, context, status: toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
// ── Provider config sync (renderer → main, keys stay encrypted) ──
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
if (!validateSenderOrSettings(event)) return { ok: false };
@@ -1689,8 +1764,14 @@ function registerHandlers(ipcMain) {
return { path: resolvedPath, version, available: true };
});
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
// When the user clicks "Refresh Status" in Settings we also want to
// rescan the shell env — otherwise a newly-exported variable in
// .zshrc stays invisible until they restart netcatty entirely.
if (options && options.refreshShellEnv) {
invalidateShellEnvCache();
}
try {
const result = await runCodexCli(["login", "status"]);
const rawOutput = [result.stdout, result.stderr]
@@ -1724,11 +1805,33 @@ function registerHandlers(ipcMain) {
}
}
// `codex login status` only reflects ~/.codex/auth.json. A user who
// configured a custom provider directly in ~/.codex/config.toml is
// functional from the CLI but would look "not_logged_in" here. Probe
// config.toml so we can surface that as a valid ready state instead of
// pushing the user into the ChatGPT login flow.
let customConfig = null;
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
try {
const shellEnv = await getShellEnv();
customConfig = readCodexCustomProviderConfig(shellEnv);
if (customConfig) {
state = "connected_custom_config";
}
} catch {
customConfig = null;
}
}
return {
state,
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
isConnected:
state === "connected_chatgpt" ||
state === "connected_api_key" ||
state === "connected_custom_config",
rawOutput: effectiveRawOutput,
exitCode: result.exitCode,
customConfig,
};
} catch (err) {
return {
@@ -1736,6 +1839,7 @@ function registerHandlers(ipcMain) {
isConnected: false,
rawOutput: err?.message || String(err),
exitCode: null,
customConfig: null,
};
}
});
@@ -1847,7 +1951,10 @@ function registerHandlers(ipcMain) {
return {
ok: true,
state,
isConnected: state === "connected_chatgpt" || state === "connected_api_key",
isConnected:
state === "connected_chatgpt" ||
state === "connected_api_key" ||
state === "connected_custom_config",
rawOutput,
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
.filter((chunk) => chunk.trim().length > 0)
@@ -2102,10 +2209,29 @@ function registerHandlers(ipcMain) {
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
// Mirror the stream handler's pre-flight: if Codex is pointed at a
// config.toml custom provider whose env_key is not exported, surface
// a targeted error instead of spawning codex-acp and letting it fail
// mid-init with an opaque message.
if (isCodexAgent && !apiKey) {
const preflight = getCodexCustomConfigPreflightError(
readCodexCustomProviderConfig(shellEnv),
);
if (preflight) {
return { ok: false, models: [], error: preflight };
}
}
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
if (apiKey) {
if (isCodexAgent && apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
// Claude agent auth is owned entirely by its CLI config/login state
// (`claude auth login`, ~/.claude settings, or ANTHROPIC_* in the user's
// shell env). netcatty's provider list must not override it.
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
@@ -2134,7 +2260,7 @@ function registerHandlers(ipcMain) {
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2182,7 +2308,7 @@ function registerHandlers(ipcMain) {
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession }) => {
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
@@ -2245,7 +2371,28 @@ function registerHandlers(ipcMain) {
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
if (isCodexAgent && !apiKey) {
// Probe ~/.codex/config.toml first so we can tell a ChatGPT user
// (needs login validation) from a custom-provider user (must NOT be
// forced through ChatGPT validation, since their auth lives in
// config.toml / shell env, not auth.json).
const codexCustomConfig = isCodexAgent && !apiKey
? readCodexCustomProviderConfig(shellEnv)
: null;
// Fail loud: custom-provider config is set but has no usable auth
// material yet (env_key is named but not exported in the shell env,
// and no api_key is hardcoded). Don't spawn — codex-acp would fail
// mid-request with an opaque "Missing environment variable" error.
const preflightError = getCodexCustomConfigPreflightError(codexCustomConfig);
if (preflightError) {
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: preflightError,
});
return { ok: false, error: `Missing env var ${codexCustomConfig.envKey}` };
}
if (isCodexAgent && !apiKey && !codexCustomConfig) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
if (shouldAbortStartup()) return { ok: true };
if (!validation.ok) {
@@ -2266,7 +2413,9 @@ function registerHandlers(ipcMain) {
}
}
const authFingerprint = isCodexAgent ? getCodexAuthFingerprint(apiKey) : null;
const authFingerprint = isCodexAgent
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
: null;
const mcpSnapshot = isCodexAgent
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
@@ -2333,9 +2482,13 @@ function registerHandlers(ipcMain) {
cleanupAcpProvider(chatSessionId);
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
if (apiKey) {
if (isCodexAgent && apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
// See comment above: Claude auth is CLI-owned, not provider-driven.
let copilotConfigInfo = null;
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
@@ -2366,7 +2519,7 @@ function registerHandlers(ipcMain) {
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2378,7 +2531,7 @@ function registerHandlers(ipcMain) {
resolvedCommand,
resolvedArgs,
mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name),
authMethodId: isCodexAgent ? (apiKey ? "codex-api-key" : "chatgpt") : null,
authMethodId: isCodexAgent ? (getCodexAuthOverride(apiKey, shellEnv).authMethodId || null) : null,
});
if (isCopilotAgent) {
@@ -2452,8 +2605,12 @@ function registerHandlers(ipcMain) {
: acpArgs || [],
env: (() => {
const fallbackEnv = withCliDiscoveryEnv(
apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
isCodexAgent && apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
);
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
fallbackEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
// See comment above: Claude auth is CLI-owned, not provider-driven.
if (isCopilotAgent) {
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
@@ -2465,7 +2622,7 @@ function registerHandlers(ipcMain) {
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
? getCodexAuthOverride(apiKey, shellEnv)
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
@@ -2513,6 +2670,7 @@ function registerHandlers(ipcMain) {
prompt,
chatSessionId,
defaultTargetSession,
userSkillsContext,
});
// Build message content: text + optional attachments

View File

@@ -34,11 +34,138 @@ let trayMenuData = {
let trayPanelWindow = null;
let trayPanelRefreshTimer = null;
// Watchdog: if `leave-full-screen` never arrives (edge case / stuck transition)
// we eventually give up and force a hide attempt. Better a visible window than
// a hung close-to-tray path.
const FULLSCREEN_LEAVE_WATCHDOG_MS = 5000;
// After `leave-full-screen` fires, macOS emits a trailing `show` event while
// the native space transition finishes. Calling `win.hide()` before that show
// causes the window to pop back on screen. We wait for the trailing show, or
// fall back on this timeout — whichever comes first.
const FULLSCREEN_TRAILING_SHOW_FALLBACK_MS = 300;
const pendingFullscreenHideByWindow = new WeakMap();
function clearPendingFullscreenHide(win) {
if (!win || typeof win !== "object") return;
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return;
if (pending.watchdogTimer) {
clearTimeout(pending.watchdogTimer);
pending.watchdogTimer = null;
}
if (pending.trailingShowTimer) {
clearTimeout(pending.trailingShowTimer);
pending.trailingShowTimer = null;
}
try {
if (pending.onLeaveFullScreen) {
win.removeListener?.("leave-full-screen", pending.onLeaveFullScreen);
}
if (pending.onClosed) {
win.removeListener?.("closed", pending.onClosed);
}
if (pending.onTrailingShow) {
win.removeListener?.("show", pending.onTrailingShow);
}
} catch {
// ignore
}
pendingFullscreenHideByWindow.delete(win);
}
function performPendingFullscreenHide(win) {
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return "cancelled";
if (!win || win.isDestroyed?.()) {
clearPendingFullscreenHide(win);
return "cancelled";
}
clearPendingFullscreenHide(win);
try {
win.hide();
return "hidden";
} catch (err) {
console.warn("[GlobalShortcut] Error hiding window after leaving fullscreen:", err);
return "failed";
}
}
function handleLeaveFullScreenForPendingHide(win) {
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return;
if (!win || win.isDestroyed?.()) {
clearPendingFullscreenHide(win);
return;
}
pending.leaveFullScreenFired = true;
if (pending.watchdogTimer) {
clearTimeout(pending.watchdogTimer);
pending.watchdogTimer = null;
}
// Wait for the trailing `show` that macOS emits as the space transition
// finishes, then hide on top of it. If it never fires within the fallback
// window, hide anyway.
pending.onTrailingShow = () => {
pending.onTrailingShow = null;
if (pending.trailingShowTimer) {
clearTimeout(pending.trailingShowTimer);
pending.trailingShowTimer = null;
}
performPendingFullscreenHide(win);
};
try {
win.once?.("show", pending.onTrailingShow);
} catch {
// ignore
}
pending.trailingShowTimer = setTimeout(() => {
pending.trailingShowTimer = null;
if (pending.onTrailingShow) {
try {
win.removeListener?.("show", pending.onTrailingShow);
} catch {
// ignore
}
pending.onTrailingShow = null;
}
performPendingFullscreenHide(win);
}, FULLSCREEN_TRAILING_SHOW_FALLBACK_MS);
}
function startPendingFullscreenHideWatchdog(win) {
const pending = pendingFullscreenHideByWindow.get(win);
if (!pending) return;
pending.watchdogTimer = setTimeout(() => {
pending.watchdogTimer = null;
if (!pendingFullscreenHideByWindow.has(win)) return;
if (!win || win.isDestroyed?.()) {
clearPendingFullscreenHide(win);
return;
}
if (pending.leaveFullScreenFired) return;
console.warn("[GlobalShortcut] Timed out waiting for leave-full-screen before hiding to tray; forcing hide");
// Give up and hide anyway. Simulate the leave path so the trailing-show
// wait still applies (defence in depth against spurious show events).
handleLeaveFullScreenForPendingHide(win);
}, FULLSCREEN_LEAVE_WATCHDOG_MS);
}
function openMainWindow() {
const { app } = electronModule;
const win = getMainWindow();
if (!win) return;
clearPendingFullscreenHide(win);
if (win.isMinimized()) win.restore();
win.show();
win.focus();
@@ -218,6 +345,65 @@ function getMainWindow() {
return mainWins && mainWins.length ? mainWins[0] : null;
}
function hideWindowRespectingMacFullscreen(win) {
if (!win || win.isDestroyed?.()) return false;
clearPendingFullscreenHide(win);
if (process.platform === "darwin" && win.isFullScreen?.()) {
// Close-to-tray on a native-fullscreen window on macOS has two traps:
//
// 1. `isFullScreen()` can flip to false BEFORE the exit animation
// completes. Polling it and calling `win.hide()` at that moment
// hides the window mid-transition, which macOS then undoes when
// the animation finishes.
// 2. Right after the real `leave-full-screen` event, macOS emits an
// internal `show` event as part of finalizing the space transition
// — this show undoes any earlier hide.
//
// Strategy: wait for `leave-full-screen`, then wait for the trailing
// `show` that follows it (or a short timeout), and only then hide.
// All legitimate "bring the window back" entry points (openMainWindow,
// toggleWindowVisibility, setCloseToTray(false), app.on("activate"),
// closed) explicitly call clearPendingFullscreenHide so we never race
// with genuine user intent.
const pending = {
watchdogTimer: null,
trailingShowTimer: null,
leaveFullScreenFired: false,
onLeaveFullScreen: null,
onClosed: null,
onTrailingShow: null,
};
pending.onLeaveFullScreen = () => {
handleLeaveFullScreenForPendingHide(win);
};
pending.onClosed = () => {
clearPendingFullscreenHide(win);
};
try {
pendingFullscreenHideByWindow.set(win, pending);
win.once?.("leave-full-screen", pending.onLeaveFullScreen);
win.once?.("closed", pending.onClosed);
startPendingFullscreenHideWatchdog(win);
win.setFullScreen(false);
return true;
} catch (err) {
clearPendingFullscreenHide(win);
console.warn("[GlobalShortcut] Error leaving fullscreen before hiding window:", err);
}
}
try {
win.hide();
return true;
} catch (err) {
console.warn("[GlobalShortcut] Error hiding window:", err);
return false;
}
}
/**
* Convert a hotkey string from frontend format to Electron accelerator format
* e.g., "⌘ + Space" -> "CommandOrControl+Space"
@@ -283,6 +469,7 @@ function toggleWindowVisibility() {
try {
// Check if window is minimized first - minimized windows may still report isVisible() = true
if (win.isMinimized()) {
clearPendingFullscreenHide(win);
win.restore();
win.show();
win.focus();
@@ -295,9 +482,10 @@ function toggleWindowVisibility() {
} else if (win.isVisible()) {
if (win.isFocused()) {
// Window is visible and focused - hide it
win.hide();
hideWindowRespectingMacFullscreen(win);
} else {
// Window is visible but not focused - focus it
clearPendingFullscreenHide(win);
win.focus();
const { app } = electronModule;
try {
@@ -308,6 +496,7 @@ function toggleWindowVisibility() {
}
} else {
// Window is hidden - show and focus it
clearPendingFullscreenHide(win);
win.show();
win.focus();
const { app } = electronModule;
@@ -437,17 +626,7 @@ function buildTrayMenuTemplate() {
menuTemplate.push({
label: "Open Main Window",
click: () => {
const win = getMainWindow();
if (win) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
try {
app.focus({ steal: true });
} catch {
// ignore
}
}
openMainWindow();
},
});
@@ -587,6 +766,7 @@ function setCloseToTray(enabled) {
createTray();
}
} else {
clearPendingFullscreenHide(getMainWindow());
// Destroy tray if it exists
destroyTray();
}
@@ -617,7 +797,7 @@ function getHotkeyStatus() {
function handleWindowClose(event, win) {
if (closeToTray && tray) {
event.preventDefault();
win.hide();
hideWindowRespectingMacFullscreen(win);
return true; // Prevented close
}
return false; // Allow close
@@ -727,5 +907,6 @@ module.exports = {
init,
registerHandlers,
handleWindowClose,
clearPendingFullscreenHide,
cleanup,
};

View File

@@ -0,0 +1,492 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
function withPatchedTimers(run) {
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
let nextTimerId = 1;
const timers = new Map();
global.setTimeout = (fn, _delay, ...args) => {
const id = nextTimerId++;
timers.set(id, () => fn(...args));
return id;
};
global.clearTimeout = (id) => {
timers.delete(id);
};
const flushNextTimer = () => {
const nextEntry = timers.entries().next().value;
if (!nextEntry) return false;
const [id, fn] = nextEntry;
timers.delete(id);
fn();
return true;
};
const getPendingTimerCount = () => timers.size;
return Promise.resolve()
.then(() => run({ flushNextTimer, getPendingTimerCount }))
.finally(() => {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
});
}
function withPatchedDateNow(initialValue, run) {
const originalDateNow = Date.now;
let currentValue = initialValue;
Date.now = () => currentValue;
return Promise.resolve()
.then(() =>
run({
setNow(nextValue) {
currentValue = nextValue;
},
}))
.finally(() => {
Date.now = originalDateNow;
});
}
function loadBridge() {
const bridgePath = require.resolve("./globalShortcutBridge.cjs");
delete require.cache[bridgePath];
return require("./globalShortcutBridge.cjs");
}
function createElectronStub() {
class FakeTray {
constructor() {
this.handlers = new Map();
}
setToolTip() {}
setContextMenu() {}
destroy() {}
on(eventName, handler) {
this.handlers.set(eventName, handler);
}
}
return {
Tray: FakeTray,
Menu: {},
BrowserWindow: {
getAllWindows() {
return [];
},
},
globalShortcut: {
register() {
return true;
},
unregister() {},
},
nativeImage: {
createFromPath() {
return {
resize() {
return this;
},
setTemplateImage() {},
};
},
createEmpty() {
return {};
},
},
app: {
getAppPath() {
return process.cwd();
},
quit() {},
},
};
}
function createIpcMainStub() {
const handlers = new Map();
return {
handlers,
handle(channel, handler) {
handlers.set(channel, handler);
},
};
}
class FakeWindow extends EventEmitter {
constructor({ fullscreen = false } = {}) {
super();
this.fullscreen = fullscreen;
this.hideCalls = 0;
this.showCalls = 0;
this.focusCalls = 0;
this.restoreCalls = 0;
this.setFullScreenCalls = [];
this.destroyed = false;
this.minimized = false;
this.visible = true;
this.focused = true;
}
isDestroyed() {
return this.destroyed;
}
isFullScreen() {
return this.fullscreen;
}
setFullScreen(nextValue) {
this.setFullScreenCalls.push(nextValue);
if (nextValue) {
this.fullscreen = true;
}
}
isMinimized() {
return this.minimized;
}
restore() {
this.restoreCalls += 1;
this.minimized = false;
}
isVisible() {
return this.visible;
}
isFocused() {
return this.focused;
}
hide() {
this.hideCalls += 1;
this.visible = false;
this.focused = false;
}
show() {
this.showCalls += 1;
this.visible = true;
this.emit("show");
}
focus() {
this.focusCalls += 1;
this.focused = true;
}
}
async function withPlatform(platform, run) {
const original = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { configurable: true, value: platform });
try {
return await run();
} finally {
Object.defineProperty(process, "platform", original);
}
}
async function enableCloseToTray(bridge, electronModule = createElectronStub()) {
bridge.init({ electronModule });
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: true });
return { ipcMain, electronModule };
}
test("handleWindowClose allows normal close when close-to-tray is disabled", () => {
const bridge = loadBridge();
const win = new FakeWindow();
let prevented = false;
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
assert.equal(result, false);
assert.equal(prevented, false);
assert.equal(win.hideCalls, 0);
});
test("close-to-tray on a mac fullscreen window defers hide until after leave-full-screen and the trailing show", async () => {
// Observed macOS sequence after the red close on a fullscreen window:
// setFullScreen(false) → (animation) → leave-full-screen → trailing show
// Hiding before the trailing show causes macOS to pop the window back
// during the final space transition. The fix waits for the trailing show
// (or a fallback timer) before calling win.hide().
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
await enableCloseToTray(bridge);
const win = new FakeWindow({ fullscreen: true });
let prevented = false;
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
assert.equal(result, true);
assert.equal(prevented, true);
assert.deepEqual(win.setFullScreenCalls, [false]);
assert.equal(win.hideCalls, 0);
// Watchdog timer is pending. No show listener yet — macOS's
// pre-leave-full-screen internal `show` events must not trigger hide.
assert.equal(getPendingTimerCount(), 1);
assert.equal(win.listenerCount("show"), 0);
// Spurious early show (mid-animation) does nothing.
win.emit("show");
assert.equal(win.hideCalls, 0);
assert.equal(getPendingTimerCount(), 1);
// leave-full-screen arrives. Watchdog cancelled; now we arm a `show`
// listener + trailing-show fallback timer. Still no hide.
win.fullscreen = false;
win.emit("leave-full-screen");
assert.equal(win.hideCalls, 0);
assert.equal(getPendingTimerCount(), 1);
assert.equal(win.listenerCount("show"), 1);
// Trailing show from macOS finalizing the space transition runs the hide.
win.emit("show");
assert.equal(win.hideCalls, 1);
assert.equal(win.listenerCount("show"), 0);
assert.equal(win.listenerCount("leave-full-screen"), 0);
assert.equal(win.listenerCount("closed"), 0);
assert.equal(getPendingTimerCount(), 0);
});
});
});
test("fallback timer hides the window when the trailing show never arrives", async () => {
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
await enableCloseToTray(bridge);
const win = new FakeWindow({ fullscreen: true });
bridge.handleWindowClose({ preventDefault() {} }, win);
win.fullscreen = false;
win.emit("leave-full-screen");
// Watchdog cleared; trailing-show fallback timer is pending.
assert.equal(getPendingTimerCount(), 1);
assert.equal(win.hideCalls, 0);
assert.equal(win.listenerCount("show"), 1);
// No show ever arrives. Fallback timer runs.
flushNextTimer();
assert.equal(win.hideCalls, 1);
assert.equal(win.listenerCount("show"), 0);
assert.equal(getPendingTimerCount(), 0);
});
});
});
test("watchdog forces the hide path if leave-full-screen never arrives", async () => {
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
await enableCloseToTray(bridge);
const win = new FakeWindow({ fullscreen: true });
bridge.handleWindowClose({ preventDefault() {} }, win);
assert.equal(getPendingTimerCount(), 1);
// Watchdog fires (simulates 5s with no leave-full-screen). It forces
// the leave path — which arms the trailing-show listener + fallback.
flushNextTimer();
assert.equal(win.hideCalls, 0);
assert.equal(getPendingTimerCount(), 1);
assert.equal(win.listenerCount("show"), 1);
// Trailing-show fallback fires → hide.
flushNextTimer();
assert.equal(win.hideCalls, 1);
assert.equal(getPendingTimerCount(), 0);
});
});
});
test("app activate clears a pending fullscreen hide", async () => {
// Regression for the close-to-tray + fullscreen bug where the internal
// `show` emitted during the fullscreen exit animation was cancelling the
// hide. main.cjs's app.on("activate") handler now calls into this bridge
// to cancel the pending hide when the user actually re-activates the app.
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
await enableCloseToTray(bridge);
const win = new FakeWindow({ fullscreen: true });
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
assert.equal(result, true);
assert.equal(getPendingTimerCount(), 1);
bridge.clearPendingFullscreenHide(win);
assert.equal(getPendingTimerCount(), 0);
assert.equal(win.listenerCount("leave-full-screen"), 0);
assert.equal(win.listenerCount("closed"), 0);
assert.equal(flushNextTimer(), false);
assert.equal(win.hideCalls, 0);
});
});
});
test("focusing a visible window cancels a pending fullscreen hide", async () => {
await withPatchedTimers(async ({ getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const win = new FakeWindow({ fullscreen: true });
win.focused = false;
electronModule.BrowserWindow.getAllWindows = () => [win];
let toggleWindow = null;
electronModule.globalShortcut.register = (_accelerator, handler) => {
toggleWindow = handler;
return true;
};
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
await ipcMain.handlers.get("netcatty:globalHotkey:register")(null, { hotkey: "Ctrl + `" });
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
assert.equal(result, true);
assert.equal(getPendingTimerCount(), 1);
toggleWindow();
assert.equal(win.focusCalls, 1);
assert.equal(getPendingTimerCount(), 0);
assert.equal(win.listenerCount("leave-full-screen"), 0);
assert.equal(win.listenerCount("closed"), 0);
});
});
});
test("openMainWindow cancels a pending fullscreen hide before showing the window", async () => {
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const win = new FakeWindow({ fullscreen: true });
win.show = function showWithoutEmit() {
this.showCalls += 1;
this.visible = true;
};
electronModule.BrowserWindow.getAllWindows = () => [win];
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
assert.equal(result, true);
assert.equal(getPendingTimerCount(), 1);
await ipcMain.handlers.get("netcatty:trayPanel:openMainWindow")();
assert.equal(win.showCalls, 1);
assert.equal(getPendingTimerCount(), 0);
const flushed = flushNextTimer();
assert.equal(flushed, false);
assert.equal(win.hideCalls, 0);
});
});
});
test("closing the window clears a pending fullscreen hide", async () => {
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
await enableCloseToTray(bridge);
const win = new FakeWindow({ fullscreen: true });
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
assert.equal(result, true);
assert.equal(getPendingTimerCount(), 1);
assert.equal(win.listenerCount("leave-full-screen"), 1);
assert.equal(win.listenerCount("closed"), 1);
win.destroyed = true;
win.emit("closed");
assert.equal(getPendingTimerCount(), 0);
assert.equal(win.listenerCount("leave-full-screen"), 0);
assert.equal(win.listenerCount("closed"), 0);
assert.equal(flushNextTimer(), false);
assert.equal(win.hideCalls, 0);
});
});
});
test("disabling close-to-tray clears a pending fullscreen hide", async () => {
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const win = new FakeWindow({ fullscreen: true });
electronModule.BrowserWindow.getAllWindows = () => [win];
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
assert.equal(result, true);
assert.equal(getPendingTimerCount(), 1);
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
assert.equal(getPendingTimerCount(), 0);
assert.equal(win.listenerCount("leave-full-screen"), 0);
assert.equal(win.listenerCount("closed"), 0);
assert.equal(flushNextTimer(), false);
assert.equal(win.hideCalls, 0);
});
});
});
test("handleWindowClose hides immediately when tray close is used outside fullscreen", async () => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
await enableCloseToTray(bridge);
const win = new FakeWindow({ fullscreen: false });
let prevented = false;
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
assert.equal(result, true);
assert.equal(prevented, true);
assert.deepEqual(win.setFullScreenCalls, []);
assert.equal(win.hideCalls, 1);
});
});
test("handleWindowClose stays in close-to-tray mode even if hide fails", async () => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
await enableCloseToTray(bridge);
const win = new FakeWindow({ fullscreen: false });
win.hide = function failingHide() {
throw new Error("hide failed");
};
let prevented = false;
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
assert.equal(result, true);
assert.equal(prevented, true);
assert.equal(win.visible, true);
});
});

View File

@@ -0,0 +1,584 @@
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const BACKUP_DIR_NAME = "vault-backups";
const BACKUP_FILE_PREFIX = "vault-backup-";
const BACKUP_FILE_EXT = ".json";
// The renderer is the untrusted input boundary for this bridge, so every
// piece of user-controlled data is validated before it reaches disk or
// propagates back into the UI. Keep these limits in sync with the
// renderer's `sanitizeLocalVaultBackupMaxCount` constants.
const MIN_MAX_COUNT = 1;
const MAX_MAX_COUNT = 100;
const DEFAULT_MAX_COUNT = 20;
// 25 MiB — two orders of magnitude above any realistic vault. A payload
// exceeding this is either a runaway test harness or a misbehaving/compromised
// renderer; refusing here prevents disk-fill DoS. The vault proper is capped
// at a much smaller size elsewhere in the app, so legitimate users never hit
// this limit.
const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024;
const ALLOWED_REASONS = new Set(["app_version_change", "before_restore"]);
// Version strings are persisted and surfaced in the Settings UI, so they
// must not carry control chars that would break logs, parsing, or
// display. Keep alphanumerics + a handful of punctuation that covers
// SemVer-ish and prerelease tags.
const VERSION_STRING_PATTERN = /^[A-Za-z0-9._+\-]{1,64}$/;
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
// Normalize a payload into a form that hashes stably across runs:
// - object keys sorted so JSON.stringify output is deterministic
// - undefined values dropped (they'd stringify as gaps anyway)
// - the TOP-LEVEL `syncedAt` timestamp is zeroed so semantically-equal
// payloads produced seconds apart still dedupe. Nested `syncedAt`
// fields (e.g. a future per-record mtime) are preserved — zeroing
// them would silently collide two semantically-different payloads
// into the same fingerprint and cause the version-change / protective
// backup dedupe to drop a backup that should have been written.
//
// INVARIANT: array order is treated as semantically meaningful and is
// NOT canonicalized. Every domain array that flows through SyncPayload
// (hosts, keys, snippets, identities, portForwardingRules, …) is
// produced by a store that iterates its internal `Map`/`Set` in a
// stable, insertion-ordered way, so two semantically-equal payloads
// built in the same renderer session produce identical orderings. If a
// future refactor introduces a non-deterministic iteration source,
// fingerprints will flap and the dedupe will miss — sort at the
// producer, not here. Sorting inside the hash function would require
// choosing a stable key per array type and would silently hide
// intentionally-reordered payloads (user dragged a host in the list)
// as "the same backup," which would be a safety regression.
function normalizePayloadForHash(value, isRoot = true) {
if (Array.isArray(value)) {
return value.map((item) => normalizePayloadForHash(item, false));
}
if (isPlainObject(value)) {
const entries = Object.entries(value)
.filter(([, item]) => item !== undefined)
.sort(([a], [b]) => a.localeCompare(b));
return entries.reduce((acc, [entryKey, entryValue]) => {
acc[entryKey] =
isRoot && entryKey === "syncedAt"
? 0
: normalizePayloadForHash(entryValue, false);
return acc;
}, {});
}
return value;
}
function stableStringify(value) {
return JSON.stringify(normalizePayloadForHash(value));
}
function computePayloadFingerprint(payload) {
return crypto
.createHash("sha256")
.update(stableStringify(payload))
.digest("hex");
}
function buildPreview(payload) {
return {
hostCount: Array.isArray(payload?.hosts) ? payload.hosts.length : 0,
keyCount: Array.isArray(payload?.keys) ? payload.keys.length : 0,
snippetCount: Array.isArray(payload?.snippets) ? payload.snippets.length : 0,
identityCount: Array.isArray(payload?.identities) ? payload.identities.length : 0,
portForwardingRuleCount: Array.isArray(payload?.portForwardingRules) ? payload.portForwardingRules.length : 0,
};
}
function toBackupSummary(record) {
return {
id: record.id,
createdAt: record.createdAt,
reason: record.reason,
sourceAppVersion: record.sourceAppVersion,
targetAppVersion: record.targetAppVersion,
preview: record.preview,
fingerprint: record.fingerprint,
};
}
// Clamp an unvalidated maxCount to the supported range. Returns
// DEFAULT_MAX_COUNT for anything non-finite or non-numeric so callers
// without a configured retention still get a sane cap.
function sanitizeMaxCount(rawMaxCount) {
const numeric = Number(rawMaxCount);
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_MAX_COUNT;
return Math.max(MIN_MAX_COUNT, Math.min(MAX_MAX_COUNT, Math.floor(numeric)));
}
function sanitizeReason(rawReason) {
// Fall back to the "before_restore" default rather than throwing — the
// default is the safer label for an unknown-cause backup, since it
// implies "this was taken defensively" in the UI.
if (typeof rawReason === "string" && ALLOWED_REASONS.has(rawReason)) {
return rawReason;
}
return "before_restore";
}
function sanitizeOptionalVersionString(value) {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (!VERSION_STRING_PATTERN.test(trimmed)) return undefined;
return trimmed;
}
// UTF-8 byte length of a payload's JSON serialization. Earlier revisions
// returned `JSON.stringify(payload).length` (UTF-16 code units), which
// under-counted by ~3x for non-ASCII vaults — a deck full of CJK snippet
// labels would report ~12.5 MiB against the 25 MiB cap when the on-wire
// size was actually 25+ MiB. `Buffer.byteLength(..., 'utf8')` gives the
// true bytes-on-disk figure.
function estimatePayloadSize(payload) {
try {
return Buffer.byteLength(JSON.stringify(payload), "utf8");
} catch {
return Infinity;
}
}
// Error thrown when the platform has no secure storage available. Backups
// would contain plaintext credentials (passwords, private keys, passphrases)
// in fields that SyncPayload carries unencrypted, so falling back to a
// plain-json file on disk would regress the vault's security posture below
// what the normal encrypted localStorage vault provides. We refuse rather
// than silently weaken the user's protection.
class VaultBackupEncryptionUnavailableError extends Error {
constructor() {
super(
"Secure storage is unavailable on this platform; vault backups cannot be created or read safely.",
);
this.name = "VaultBackupEncryptionUnavailableError";
this.code = "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE";
}
}
class VaultBackupTooLargeError extends Error {
constructor(size) {
super(
`Vault backup payload exceeds maximum allowed size (${size} > ${MAX_PAYLOAD_BYTES}).`,
);
this.name = "VaultBackupTooLargeError";
this.code = "VAULT_BACKUP_TOO_LARGE";
}
}
function isSafeStorageAvailable(safeStorage) {
return Boolean(safeStorage?.isEncryptionAvailable?.());
}
function encodePayload(payload, safeStorage) {
if (!isSafeStorageAvailable(safeStorage)) {
throw new VaultBackupEncryptionUnavailableError();
}
const raw = JSON.stringify(payload);
return {
encoding: "safeStorage-v1",
data: safeStorage.encryptString(raw).toString("base64"),
};
}
function decodePayload(record, safeStorage) {
if (record.payloadEncoding === "safeStorage-v1") {
if (!safeStorage?.decryptString || !isSafeStorageAvailable(safeStorage)) {
throw new VaultBackupEncryptionUnavailableError();
}
const decrypted = safeStorage.decryptString(Buffer.from(record.payloadData, "base64"));
return JSON.parse(decrypted);
}
// Legacy "plain-json-v1" records may exist from an earlier build; read
// them once so users can migrate their data, but never write new ones.
if (record.payloadEncoding === "plain-json-v1") {
return JSON.parse(record.payloadData);
}
throw new Error(`Unsupported vault backup encoding: ${record.payloadEncoding}`);
}
// Upper bound for a backup file on disk. The plaintext payload is capped
// at MAX_PAYLOAD_BYTES on write; the encrypted-and-base64-encoded record
// plus JSON envelope inflates that by ~2x worst case (base64 adds ~33%,
// JSON formatting adds some, and the record metadata rounds up). A 2x
// multiplier leaves comfortable headroom for legitimate backups while
// still rejecting a 100+ MiB file that a user (or attacker) dropped
// into the backup directory manually.
const MAX_BACKUP_FILE_BYTES = MAX_PAYLOAD_BYTES * 2;
async function readBackupRecord(filePath) {
// Refuse oversized files BEFORE readFile. `fs.readFile` buffers the
// whole file into memory, so an attacker (or a corrupted state) that
// places a huge file in the backup dir could OOM the renderer during
// listBackups enumeration. Stat-then-read keeps the failure mode to
// a cheap rejection.
let stat;
try {
stat = await fs.promises.stat(filePath);
} catch (error) {
throw new Error(`Unable to stat vault backup ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
if (stat.size > MAX_BACKUP_FILE_BYTES) {
throw new VaultBackupTooLargeError(stat.size);
}
const raw = await fs.promises.readFile(filePath, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") {
throw new Error(`Invalid vault backup record: ${filePath}`);
}
return parsed;
}
async function listBackupRecords(dirPath) {
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const records = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith(BACKUP_FILE_PREFIX) || !entry.name.endsWith(BACKUP_FILE_EXT)) continue;
const fullPath = path.join(dirPath, entry.name);
try {
const record = await readBackupRecord(fullPath);
records.push({ record, filePath: fullPath });
} catch (error) {
console.warn("[vaultBackupBridge] Failed to parse backup:", fullPath, error);
}
}
records.sort((a, b) => {
const aTime = Number(a.record.createdAt || 0);
const bTime = Number(b.record.createdAt || 0);
if (aTime !== bTime) return bTime - aTime;
// Stable, deterministic tiebreak when two backups share a millisecond
// (rapid successive creates, clock quantization). Without this the
// retention trimmer's "delete the oldest" pass is order-dependent and
// can drop a different record across list() → prune() passes.
const aId = String(a.record.id || '');
const bId = String(b.record.id || '');
return bId.localeCompare(aId);
});
return records;
}
// Delete old backups, trusting the caller-provided `records` list when
// supplied to avoid a redundant directory scan. `createBackup` has just
// scanned + written, so it passes its freshly-enumerated records through
// here. External callers (retention-change UI, trim IPC) rescan.
async function pruneBackupRecords(dirPath, maxCount, records = null) {
const sanitizedMaxCount = sanitizeMaxCount(maxCount);
const sourceRecords = records ?? (await listBackupRecords(dirPath));
const toDelete = sourceRecords.slice(sanitizedMaxCount);
let deletedCount = 0;
for (const entry of toDelete) {
try {
await fs.promises.unlink(entry.filePath);
deletedCount += 1;
} catch (error) {
console.warn("[vaultBackupBridge] Failed to delete old backup:", entry.filePath, error);
}
}
return {
deletedCount,
keptCount: Math.min(sourceRecords.length, sanitizedMaxCount),
};
}
function createVaultBackupService({ app, safeStorage, shell }) {
if (!app?.getPath) {
throw new Error("Electron app is unavailable.");
}
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
// Serialize createBackup so two concurrent calls (version-change backup
// running at startup + an explicit protective-before-restore triggered
// by the user's click, etc.) observe each other's writes. Without this,
// both observers would see an empty directory, compute the same
// fingerprint, skip the dedupe, and write two identical files.
let createBackupLock = Promise.resolve();
// Monotonically increasing `createdAt` per service instance. `Date.now()`
// has 1ms resolution and back-to-back async calls (version-change backup
// followed immediately by a protective backup) can land in the same
// millisecond, producing ties that `listBackupRecords` cannot resolve
// (the sort has no tiebreaker). Bumping ensures strict ordering so
// callers always see the true newest record first.
let lastCreatedAt = 0;
return {
isEncryptionAvailable() {
return isSafeStorageAvailable(safeStorage);
},
async createBackup(options = {}) {
const next = createBackupLock.then(() => doCreateBackup(options));
// Swallow the rejection on the lock chain so one caller's error
// does not poison subsequent calls; each individual await sees its
// own rejection via the `next` return.
createBackupLock = next.catch(() => undefined);
return next;
},
async listBackups() {
const records = await listBackupRecords(getBackupDir());
return records.map(({ record }) => toBackupSummary(record));
},
async readBackup(options = {}) {
const backupId = typeof options.id === "string" ? options.id : "";
if (!backupId) {
throw new Error("Missing vault backup id.");
}
const records = await listBackupRecords(getBackupDir());
const match = records.find(({ record }) => record.id === backupId);
if (!match) {
throw new Error("Vault backup not found.");
}
return {
backup: toBackupSummary(match.record),
payload: decodePayload(match.record, safeStorage),
};
},
async trimBackups(options = {}) {
return pruneBackupRecords(getBackupDir(), options.maxCount);
},
async openBackupDir() {
const dirPath = getBackupDir();
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
if (shell?.openPath) {
const errorMessage = await shell.openPath(dirPath);
if (errorMessage) {
throw new Error(errorMessage);
}
}
return {
success: true,
path: dirPath,
};
},
};
async function doCreateBackup(options) {
const payload = options.payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("Missing vault backup payload.");
}
// Refuse early when the payload is too large to prevent a
// misbehaving or compromised renderer from filling the disk. The
// check runs before any side effect so callers see a deterministic
// failure rather than a partial write.
const estimatedSize = estimatePayloadSize(payload);
if (estimatedSize > MAX_PAYLOAD_BYTES) {
throw new VaultBackupTooLargeError(estimatedSize);
}
// Refuse before doing anything side-effectful so callers get a clear
// error rather than a silently-weakened plaintext backup.
if (!isSafeStorageAvailable(safeStorage)) {
throw new VaultBackupEncryptionUnavailableError();
}
const dirPath = getBackupDir();
const existingRecords = await listBackupRecords(dirPath);
const fingerprint = computePayloadFingerprint(payload);
const latest = existingRecords[0]?.record ?? null;
if (latest?.fingerprint === fingerprint) {
return {
created: false,
backup: toBackupSummary(latest),
};
}
let createdAt = Date.now();
if (createdAt <= lastCreatedAt) createdAt = lastCreatedAt + 1;
lastCreatedAt = createdAt;
const id = crypto.randomUUID();
const preview = buildPreview(payload);
const encoded = encodePayload(payload, safeStorage);
const record = {
formatVersion: 1,
id,
createdAt,
reason: sanitizeReason(options.reason),
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
fingerprint,
preview,
payloadEncoding: encoded.encoding,
payloadData: encoded.data,
};
const filePath = path.join(
dirPath,
`${BACKUP_FILE_PREFIX}${createdAt}-${id}${BACKUP_FILE_EXT}`,
);
// Durable atomic write: serialize to a sibling tmp file, fsync the
// file's data+metadata to stable storage, rename into place, then
// fsync the directory entry itself. Without the file fsync a system
// crash between writeFile and rename can leave the OS with a
// successfully-renamed entry whose data blocks are still only in
// page cache — the file is visible but reads back as zeros or torn
// content. Without the directory fsync the rename itself may not be
// durable: on recovery listBackups sees an empty directory even
// though the file's blocks made it to disk. Both matter for the
// protective-before-restore case, where the user is about to
// overwrite their vault and the safety net MUST survive a crash
// between backup and restore.
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
let tmpHandle;
try {
tmpHandle = await fs.promises.open(tmpPath, 'w', 0o600);
await tmpHandle.writeFile(`${JSON.stringify(record, null, 2)}\n`);
await tmpHandle.sync();
} finally {
if (tmpHandle) {
try {
await tmpHandle.close();
} catch {
/* ignore — close failure after successful sync still leaves
data durable on disk */
}
}
}
try {
await fs.promises.rename(tmpPath, filePath);
} catch (renameError) {
// Best-effort cleanup; swallow unlink errors so the rename error
// surfaces to the caller.
try {
await fs.promises.unlink(tmpPath);
} catch {
/* ignore */
}
throw renameError;
}
// fsync the directory so the rename itself is durably recorded.
// On Linux this is required; on macOS it is a no-op at the FS
// layer but still safe and portable. On Windows fs.open on a
// directory is not supported — the rename is durable as part of
// NTFS's journal, so skip the sync there.
if (process.platform !== 'win32') {
let dirHandle;
try {
dirHandle = await fs.promises.open(dirPath, 'r');
await dirHandle.sync();
} catch (dirSyncError) {
// Directory fsync is a defense-in-depth hardening step — if
// the filesystem refuses (tmpfs, some network mounts) the
// rename already happened and the file is reachable, so a
// failure here should not abort the backup. Log so a
// systematic issue is diagnosable.
console.warn('[vaultBackupBridge] Directory fsync failed:', dirSyncError);
} finally {
if (dirHandle) {
try {
await dirHandle.close();
} catch {
/* ignore */
}
}
}
}
// Reuse the enumeration we already did for dedupe, prepending the
// newly-written record so pruneBackupRecords can trim without
// re-scanning the directory. Records are ordered newest-first.
const nextRecords = [{ record, filePath }, ...existingRecords];
await pruneBackupRecords(dirPath, options.maxCount, nextRecords);
return {
created: true,
backup: toBackupSummary(record),
};
}
}
function registerHandlers(ipcMain, electronModule) {
const service = createVaultBackupService({
app: electronModule?.app,
safeStorage: electronModule?.safeStorage,
shell: electronModule?.shell,
});
const BrowserWindow = electronModule?.BrowserWindow;
// Broadcast a backup-changed event to every renderer so other windows
// (notably the Settings window's backup list) can refresh without the
// user manually navigating. Any successful create / trim path calls
// this. Failures fall through silently — a dropped notification is
// recoverable on the next manual refresh, while re-throwing here
// would turn a harmless broadcast failure into a user-visible error.
const broadcastBackupsChanged = () => {
if (!BrowserWindow?.getAllWindows) return;
try {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed?.()) continue;
try {
win.webContents?.send?.("netcatty:vaultBackups:changed");
} catch (error) {
console.warn("[vaultBackupBridge] Failed to notify window:", error);
}
}
} catch (error) {
console.warn("[vaultBackupBridge] Broadcast failed:", error);
}
};
ipcMain.handle("netcatty:vaultBackups:capabilities", async () => {
return { encryptionAvailable: service.isEncryptionAvailable() };
});
ipcMain.handle("netcatty:vaultBackups:create", async (_event, payload) => {
const result = await service.createBackup(payload || {});
// Only broadcast when a new record was actually written; a
// deduped (created=false) return means the on-disk state did not
// change, so other windows already show the latest backup.
if (result?.created) {
broadcastBackupsChanged();
}
return result;
});
ipcMain.handle("netcatty:vaultBackups:list", async () => {
return service.listBackups();
});
ipcMain.handle("netcatty:vaultBackups:read", async (_event, payload) => {
return service.readBackup(payload || {});
});
ipcMain.handle("netcatty:vaultBackups:trim", async (_event, payload) => {
const result = await service.trimBackups(payload || {});
if (result?.deletedCount) {
broadcastBackupsChanged();
}
return result;
});
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
return service.openBackupDir();
});
}
module.exports = {
BACKUP_DIR_NAME,
BACKUP_FILE_EXT,
BACKUP_FILE_PREFIX,
MAX_PAYLOAD_BYTES,
VaultBackupEncryptionUnavailableError,
VaultBackupTooLargeError,
buildPreview,
computePayloadFingerprint,
createVaultBackupService,
registerHandlers,
};

View File

@@ -0,0 +1,626 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
BACKUP_DIR_NAME,
MAX_PAYLOAD_BYTES,
VaultBackupEncryptionUnavailableError,
VaultBackupTooLargeError,
createVaultBackupService,
} = require("./vaultBackupBridge.cjs");
function createTempRoot() {
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-vault-backup-"));
}
// All tests default to encrypted=true because the bridge now refuses to
// write plaintext backups (I1). Individual tests opt out to verify the
// refusal path.
function createService(rootDir, { encrypted = true } = {}) {
const app = {
getPath(key) {
if (key !== "userData") throw new Error(`Unexpected path key: ${key}`);
return rootDir;
},
};
const safeStorage = encrypted
? {
isEncryptionAvailable() {
return true;
},
encryptString(value) {
return Buffer.from(`enc:${value}`, "utf8");
},
decryptString(buffer) {
const decoded = Buffer.from(buffer).toString("utf8");
if (!decoded.startsWith("enc:")) throw new Error("Bad payload");
return decoded.slice(4);
},
}
: {
isEncryptionAvailable() {
return false;
},
};
return createVaultBackupService({
app,
safeStorage,
shell: {
openPath: async () => "",
},
});
}
function samplePayload(overrides = {}) {
return {
hosts: [
{
id: "h1",
label: "prod",
hostname: "prod",
username: "root",
port: 22,
os: "linux",
group: "",
tags: [],
protocol: "ssh",
},
],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: Date.now(),
...overrides,
};
}
test("vault backups round-trip and dedupe identical payloads", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
const payload = samplePayload();
try {
const first = await service.createBackup({
payload,
reason: "app_version_change",
sourceAppVersion: "1.0.89",
targetAppVersion: "1.0.90",
maxCount: 5,
});
assert.equal(first.created, true);
assert.equal(first.backup.reason, "app_version_change");
const duplicate = await service.createBackup({
payload: { ...payload, syncedAt: Date.now() + 1000 },
reason: "before_restore",
maxCount: 5,
});
assert.equal(duplicate.created, false);
assert.equal(duplicate.backup.id, first.backup.id);
const listed = await service.listBackups();
assert.equal(listed.length, 1);
assert.equal(listed[0].preview.hostCount, 1);
const restored = await service.readBackup({ id: first.backup.id });
assert.equal(restored.backup.id, first.backup.id);
assert.equal(restored.payload.hosts[0].label, "prod");
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("vault backups honor retention trimming and can use encrypted payload storage", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir, { encrypted: true });
try {
for (let index = 0; index < 3; index += 1) {
await service.createBackup({
payload: {
hosts: [{ id: `h${index}`, label: `host-${index}`, hostname: `host-${index}`, username: "root", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: Date.now() + index,
},
reason: "before_restore",
maxCount: 2,
});
}
const listed = await service.listBackups();
assert.equal(listed.length, 2);
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
const fileNames = fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"));
assert.equal(fileNames.length, 2);
const newest = listed[0];
const restored = await service.readBackup({ id: newest.id });
assert.equal(restored.payload.hosts[0].id, "h2");
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
// ============================================================================
// I1 — plaintext refusal when safeStorage is unavailable
// ============================================================================
test("createBackup refuses when safeStorage is unavailable (I1)", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir, { encrypted: false });
try {
await assert.rejects(
() => service.createBackup({ payload: samplePayload() }),
(err) => {
assert.ok(err instanceof VaultBackupEncryptionUnavailableError);
assert.equal(err.code, "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE");
return true;
},
);
// Critical: nothing should have been written to disk. Earlier versions
// silently wrote a plain-json-v1 record here, leaking plaintext
// credentials (see review I1).
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
const files = fs.existsSync(backupDir)
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
: [];
assert.equal(files.length, 0);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("isEncryptionAvailable reports safeStorage state accurately", () => {
const rootDir = createTempRoot();
try {
assert.equal(createService(rootDir, { encrypted: true }).isEncryptionAvailable(), true);
assert.equal(createService(rootDir, { encrypted: false }).isEncryptionAvailable(), false);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
// ============================================================================
// Atomic writes and listBackups resilience
// ============================================================================
test("listBackups ignores .tmp files left by an interrupted write", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
await service.createBackup({ payload: samplePayload() });
// Simulate a crash mid-write: drop a dangling .tmp file matching the
// backup naming convention but with the atomic-write suffix.
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
const tmpPath = path.join(
backupDir,
`vault-backup-${Date.now()}-abc.json.tmp-deadbeef`,
);
fs.writeFileSync(tmpPath, "{ half written", { mode: 0o600 });
const listed = await service.listBackups();
// The legitimate backup is still there; the .tmp file is ignored
// because it does not end in ".json".
assert.equal(listed.length, 1);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("listBackups tolerates a corrupted backup file by skipping it", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const ok = await service.createBackup({ payload: samplePayload() });
assert.ok(ok.created);
// Drop a syntactically-invalid backup alongside the real one.
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
const bogusPath = path.join(backupDir, `vault-backup-${Date.now() + 1}-bad.json`);
fs.writeFileSync(bogusPath, "{ this is not json", { mode: 0o600 });
// Must not throw — the bad file is logged-and-skipped.
const listed = await service.listBackups();
assert.equal(listed.length, 1, "corrupted file should be skipped, valid remains");
assert.equal(listed[0].id, ok.backup.id);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
// ============================================================================
// Legacy plain-json-v1 migration path
// ============================================================================
test("readBackup can still read legacy plain-json-v1 records for migration", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
try {
// Hand-craft a legacy record that would have been produced by the
// pre-I1 code path. Users on that build must still be able to read
// and migrate off of these files.
const createdAt = Date.now();
const id = "legacy-record-id";
const payload = samplePayload();
const record = {
formatVersion: 1,
id,
createdAt,
reason: "before_restore",
fingerprint: "legacy",
preview: {
hostCount: 1,
keyCount: 0,
snippetCount: 0,
identityCount: 0,
portForwardingRuleCount: 0,
},
payloadEncoding: "plain-json-v1",
payloadData: JSON.stringify(payload),
};
fs.writeFileSync(
path.join(backupDir, `vault-backup-${createdAt}-${id}.json`),
JSON.stringify(record, null, 2),
{ mode: 0o600 },
);
const restored = await service.readBackup({ id });
assert.equal(restored.payload.hosts[0].id, "h1");
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("readBackup throws a clear error for unknown payloadEncoding", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
try {
const record = {
formatVersion: 1,
id: "future-record",
createdAt: Date.now(),
reason: "before_restore",
fingerprint: "future",
preview: { hostCount: 0, keyCount: 0, snippetCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
payloadEncoding: "future-algo-v9",
payloadData: "unreadable",
};
fs.writeFileSync(
path.join(backupDir, `vault-backup-${record.createdAt}-future.json`),
JSON.stringify(record),
{ mode: 0o600 },
);
await assert.rejects(
() => service.readBackup({ id: "future-record" }),
/Unsupported vault backup encoding/,
);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
// ============================================================================
// Hash normalization (I8)
// ============================================================================
// ============================================================================
// Input validation (review Important #4)
// ============================================================================
test("createBackup rejects a payload larger than MAX_PAYLOAD_BYTES", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
// Build a payload whose JSON serialization exceeds the cap. A single
// large string field is the cheapest way to push past the limit without
// an actual 25MB in-memory blob per field.
const giant = "x".repeat(MAX_PAYLOAD_BYTES + 1);
const oversized = samplePayload({ __bloat: giant });
await assert.rejects(
() => service.createBackup({ payload: oversized }),
(err) => {
assert.ok(err instanceof VaultBackupTooLargeError);
assert.equal(err.code, "VAULT_BACKUP_TOO_LARGE");
return true;
},
);
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
const files = fs.existsSync(backupDir)
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
: [];
assert.equal(files.length, 0, "oversized payload must not land on disk");
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup normalizes an out-of-range reason to 'before_restore'", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const first = await service.createBackup({
payload: samplePayload(),
reason: "__INJECTED__\r\nlog-spoofed",
});
assert.equal(first.created, true);
assert.equal(
first.backup.reason,
"before_restore",
"unknown reason must fall back to the safe enum default",
);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup strips version strings with control chars or weird punctuation", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const result = await service.createBackup({
payload: samplePayload(),
reason: "app_version_change",
sourceAppVersion: "1.0.0\nrm -rf /",
targetAppVersion: " ",
});
assert.equal(result.created, true);
assert.equal(result.backup.sourceAppVersion, undefined);
assert.equal(result.backup.targetAppVersion, undefined);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup accepts a legitimate SemVer-ish version string", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const result = await service.createBackup({
payload: samplePayload(),
reason: "app_version_change",
sourceAppVersion: "1.0.89",
targetAppVersion: "2.0.0-rc.1",
});
assert.equal(result.created, true);
assert.equal(result.backup.sourceAppVersion, "1.0.89");
assert.equal(result.backup.targetAppVersion, "2.0.0-rc.1");
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup rejects an array payload (not an object)", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
await assert.rejects(
() => service.createBackup({ payload: [] }),
/Missing vault backup payload/,
);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("trimBackups clamps out-of-range maxCount instead of silently defaulting", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
// Seed several backups.
for (let i = 0; i < 3; i += 1) {
await service.createBackup({
payload: samplePayload({ hosts: [{ id: `h${i}`, label: `h${i}`, hostname: `h${i}`, username: "u", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }] }),
});
}
// maxCount = 0 is out of range → clamped to DEFAULT (20), nothing deleted.
const zeroResult = await service.trimBackups({ maxCount: 0 });
assert.equal(zeroResult.deletedCount, 0);
assert.equal((await service.listBackups()).length, 3);
// maxCount = 200 clamps to 100, no-op on a 3-entry set.
const hugeResult = await service.trimBackups({ maxCount: 200 });
assert.equal(hugeResult.deletedCount, 0);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
// ============================================================================
// Concurrency (review Important #5)
// ============================================================================
test("concurrent createBackup calls with identical payloads dedupe via the mutex", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
const payload = samplePayload();
try {
// Fire N parallel requests with the same payload. Without the mutex,
// each call would observe an empty directory in its own tick, skip
// dedupe, and write a distinct file. With the mutex, the first call
// writes and each subsequent call observes the previous write and
// dedupes.
const results = await Promise.all(
Array.from({ length: 5 }, () =>
service.createBackup({ payload, reason: "before_restore" }),
),
);
const created = results.filter((r) => r.created);
const deduped = results.filter((r) => !r.created);
assert.equal(created.length, 1, "exactly one concurrent call should create a new backup");
assert.equal(deduped.length, 4);
// All results point at the same id — the first one's.
const canonicalId = created[0].backup.id;
for (const r of deduped) {
assert.equal(r.backup.id, canonicalId);
}
// Disk state confirms only one file landed.
const listed = await service.listBackups();
assert.equal(listed.length, 1);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("a failing createBackup does not poison the mutex for subsequent calls", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
// First call rejects (invalid payload).
await assert.rejects(
() => service.createBackup({ payload: null }),
/Missing vault backup payload/,
);
// Next call must still succeed — the mutex chain kept moving.
const ok = await service.createBackup({ payload: samplePayload() });
assert.equal(ok.created, true);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("fingerprint is stable when top-level syncedAt drifts", async () => {
// The bridge zeros top-level syncedAt inside normalizePayloadForHash
// so semantically-equal payloads dedupe. This guards the dedupe path
// the createBackup test already covers, from the reverse direction.
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const base = samplePayload({ syncedAt: 0 });
const first = await service.createBackup({ payload: { ...base, syncedAt: 1 } });
const second = await service.createBackup({ payload: { ...base, syncedAt: 9_999_999 } });
assert.equal(first.created, true);
assert.equal(second.created, false, "differs only by top-level syncedAt → dedupe");
assert.equal(second.backup.id, first.backup.id);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("fingerprint treats nested syncedAt as load-bearing (C1)", async () => {
// The top-level `syncedAt` is zeroed so two payloads that differ only in
// when-they-were-packaged still dedupe. But that zeroing must NOT cascade
// into nested objects — a future schema where any child record carries
// its own `syncedAt` could otherwise collide into a false dedupe, and
// the version-change / protective backup would be silently skipped.
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const makeNested = (nestedSyncedAt) =>
samplePayload({
syncedAt: 0,
hosts: [
{
id: "h1",
label: "prod",
hostname: "prod",
username: "root",
port: 22,
os: "linux",
group: "",
tags: [],
protocol: "ssh",
syncedAt: nestedSyncedAt,
},
],
});
const first = await service.createBackup({ payload: makeNested(111) });
const second = await service.createBackup({ payload: makeNested(222) });
assert.equal(first.created, true);
assert.equal(
second.created,
true,
"nested syncedAt must NOT be zeroed — payloads are semantically different",
);
assert.notEqual(second.backup.id, first.backup.id);
assert.notEqual(second.backup.fingerprint, first.backup.fingerprint);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("readBackupRecord rejects oversized files before buffering them", async () => {
// Write-path already caps at MAX_PAYLOAD_BYTES; this guards the READ
// path against a pre-existing or externally-placed file larger than
// the bound, which would otherwise be slurped into memory by
// fs.readFile inside listBackups/readBackup and risk OOMing the
// renderer. The cap is 2x the write cap to allow for the base64 +
// JSON-envelope inflation of legitimate records.
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
// Seed a legitimate backup so the directory exists and listBackups
// has something to iterate past.
const ok = await service.createBackup({ payload: samplePayload() });
assert.ok(ok.created);
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
const hugePath = path.join(
backupDir,
`vault-backup-${Date.now() + 1}-huge.json`,
);
// MAX_PAYLOAD_BYTES * 2 = 50 MiB; we write one byte past that.
const hugeSize = MAX_PAYLOAD_BYTES * 2 + 1;
// Pre-allocate the file without actually writing 50 MiB of content:
// `ftruncate` produces a sparse file of the requested size on every
// supported filesystem, so the test stays fast and uses minimal disk.
const fd = fs.openSync(hugePath, "w", 0o600);
try {
fs.ftruncateSync(fd, hugeSize);
} finally {
fs.closeSync(fd);
}
// listBackups now enumerates both files; the huge one should be
// skipped with a warning (matching the corrupted-file behavior) and
// the valid one must still come back.
const listed = await service.listBackups();
assert.equal(
listed.length,
1,
"oversized file should be skipped during enumeration",
);
assert.equal(listed[0].id, ok.backup.id);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});

View File

@@ -164,6 +164,7 @@ const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
// GPU settings
// NOTE: Do not disable Chromium sandbox by default.
@@ -332,6 +333,12 @@ function focusMainWindow() {
}
} catch {}
// Cancel any in-flight close-to-tray hide so second-instance / dock-click
// re-entry beats a pending leave-full-screen → hide sequence.
try {
getGlobalShortcutBridge().clearPendingFullscreenHide?.(win);
} catch {}
try {
if (win.isMinimized && win.isMinimized()) win.restore();
} catch {}
@@ -408,6 +415,7 @@ const registerBridges = (win) => {
const credentialBridge = getCredentialBridge();
const autoUpdateBridge = getAutoUpdateBridge();
const aiBridge = getAiBridge();
const vaultBackupBridge = getVaultBackupBridge();
const getCloudSyncPasswordPath = () => {
try {
@@ -507,6 +515,7 @@ const registerBridges = (win) => {
autoUpdateBridge.registerHandlers(ipcMain);
aiBridge.registerHandlers(ipcMain);
crashLogBridge.registerHandlers(ipcMain);
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
// ZMODEM cancel handler
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
@@ -1068,6 +1077,12 @@ if (!gotLock) {
try {
const mainWin = getWindowManager().getMainWindow?.();
if (mainWin && !mainWin.isDestroyed?.()) {
// If a close-to-tray hide is still pending (fullscreen exit animation
// not finished yet), cancel it — user intent to bring the window
// back overrides the pending hide.
try {
getGlobalShortcutBridge().clearPendingFullscreenHide?.(mainWin);
} catch {}
if (mainWin.isMinimized?.()) mainWin.restore();
mainWin.show?.();
mainWin.focus?.();

View File

@@ -858,6 +858,35 @@ const api = {
// App info
getAppInfo: () => ipcRenderer.invoke("netcatty:app:getInfo"),
getVaultBackupCapabilities: () =>
ipcRenderer.invoke("netcatty:vaultBackups:capabilities"),
createVaultBackup: (payload) =>
ipcRenderer.invoke("netcatty:vaultBackups:create", payload),
listVaultBackups: () =>
ipcRenderer.invoke("netcatty:vaultBackups:list"),
readVaultBackup: (payload) =>
ipcRenderer.invoke("netcatty:vaultBackups:read", payload),
trimVaultBackups: (payload) =>
ipcRenderer.invoke("netcatty:vaultBackups:trim", payload),
openVaultBackupDir: () =>
ipcRenderer.invoke("netcatty:vaultBackups:openDir"),
// Subscribe to cross-window "backups changed" events emitted by the
// main process whenever a create/trim actually mutated the on-disk
// set. Returns an unsubscribe function so React-style consumers can
// release the listener on unmount without leaking IPC handlers.
onVaultBackupsChanged: (handler) => {
if (typeof handler !== "function") return () => {};
const listener = () => {
try { handler(); } catch (error) {
console.warn("[preload] onVaultBackupsChanged handler threw:", error);
}
};
ipcRenderer.on("netcatty:vaultBackups:changed", listener);
return () => {
try { ipcRenderer.removeListener("netcatty:vaultBackups:changed", listener); }
catch { /* ignore */ }
};
},
// Tell main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady: () => ipcRenderer.send("netcatty:renderer:ready"),
@@ -1184,8 +1213,8 @@ const api = {
aiResolveCli: async (params) => {
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);
},
aiCodexGetIntegration: async () => {
return ipcRenderer.invoke("netcatty:ai:codex:get-integration");
aiCodexGetIntegration: async (options) => {
return ipcRenderer.invoke("netcatty:ai:codex:get-integration", options);
},
aiCodexStartLogin: async () => {
return ipcRenderer.invoke("netcatty:ai:codex:start-login");
@@ -1230,6 +1259,15 @@ const api = {
aiMcpSetToolIntegrationMode: async (mode) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-tool-integration-mode", { mode });
},
aiUserSkillsGetStatus: async () => {
return ipcRenderer.invoke("netcatty:ai:user-skills:status");
},
aiUserSkillsOpenFolder: async () => {
return ipcRenderer.invoke("netcatty:ai:user-skills:open");
},
aiUserSkillsBuildContext: async (prompt, selectedSkillSlugs) => {
return ipcRenderer.invoke("netcatty:ai:user-skills:build-context", { prompt, selectedSkillSlugs });
},
// MCP approval gate: renderer receives approval requests from main process
onMcpApprovalRequest: (cb) => {
const handler = (_event, payload) => cb(payload);
@@ -1246,8 +1284,8 @@ const api = {
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
},
// ACP streaming
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession });
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext });
},
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });

135
global.d.ts vendored
View File

@@ -6,16 +6,13 @@ declare module "*.cjs" {
export = value;
}
declare global {
// Extend HTMLInputElement to support webkitdirectory attribute
namespace JSX {
interface IntrinsicElements {
input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement> & {
webkitdirectory?: string;
}, HTMLInputElement>;
}
declare module 'react' {
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
webkitdirectory?: string | boolean;
}
}
declare global {
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
@@ -515,6 +512,69 @@ declare global {
// App info (name/version/platform) for About screens
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
getVaultBackupCapabilities?(): Promise<{ encryptionAvailable: boolean }>;
createVaultBackup?(payload: {
payload: import('./domain/sync').SyncPayload;
reason: 'app_version_change' | 'before_restore';
sourceAppVersion?: string;
targetAppVersion?: string;
maxCount?: number;
}): Promise<{
created: boolean;
backup: {
id: string;
createdAt: number;
reason: 'app_version_change' | 'before_restore';
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
} | null;
}>;
listVaultBackups?(): Promise<Array<{
id: string;
createdAt: number;
reason: 'app_version_change' | 'before_restore';
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
}>>;
readVaultBackup?(payload: { id: string }): Promise<{
backup: {
id: string;
createdAt: number;
reason: 'app_version_change' | 'before_restore';
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
};
payload: import('./domain/sync').SyncPayload;
}>;
trimVaultBackups?(payload: { maxCount: number }): Promise<{ deletedCount: number; keptCount: number }>;
openVaultBackupDir?(): Promise<{ success: boolean; path: string }>;
// Subscribe to main-process-driven "vault backups changed" events.
// Returns an unsubscribe callback. Undefined in non-Electron builds.
onVaultBackupsChanged?(handler: () => void): () => void;
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady?(): void;
@@ -732,11 +792,21 @@ declare global {
acpCommand?: string;
acpArgs?: string[];
}>>;
aiCodexGetIntegration?(): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'not_logged_in' | 'unknown';
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean }): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: {
providerName: string;
displayName: string;
baseUrl: string | null;
envKey: string | null;
envKeyPresent: boolean;
hasHardcodedApiKey: boolean;
model: string | null;
authHash: string | null;
} | null;
}>;
aiCodexStartLogin?(): Promise<{
ok: boolean;
@@ -795,11 +865,54 @@ declare global {
connected: boolean;
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
aiMcpSetToolIntegrationMode?(mode: 'mcp' | 'skills'): Promise<{ ok: boolean; error?: string }>;
aiUserSkillsGetStatus?(): Promise<{
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: Array<{
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: 'ready' | 'warning';
warnings: string[];
}>;
warnings?: string[];
error?: string;
}>;
aiUserSkillsOpenFolder?(): Promise<{
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: Array<{
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: 'ready' | 'warning';
warnings: string[];
}>;
warnings?: string[];
error?: string;
}>;
aiUserSkillsBuildContext?(prompt: string, selectedSkillSlugs?: string[]): Promise<{
ok: boolean;
context?: string;
error?: string;
}>;
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;

View File

@@ -48,6 +48,7 @@ interface AcpBridge {
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
@@ -87,6 +88,7 @@ export async function runAcpAgentTurn(
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<void> {
const acpBridge = bridge as unknown as AcpBridge;
@@ -161,6 +163,7 @@ export async function runAcpAgentTurn(
images?.length ? images : undefined,
toolIntegrationMode,
defaultTargetSession,
userSkillsContext,
).then((result) => {
if (result?.ok === false) {
settle(() => {

View File

@@ -14,10 +14,11 @@ export interface SystemPromptContext {
}>;
permissionMode: 'observer' | 'confirm' | 'autonomous';
webSearchEnabled?: boolean;
userSkillsContext?: string;
}
export function buildSystemPrompt(context: SystemPromptContext): string {
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled } = context;
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled, userSkillsContext } = context;
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
const hostList = buildHostList(hosts);
@@ -59,7 +60,8 @@ ${permissionRules}
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}
${userSkillsContext ? `\n\n## User Skills\n\n${userSkillsContext}` : ''}`;
}
function buildScopeDescription(

View File

@@ -67,3 +67,4 @@ export function getManagedAgentStoredPath(
);
return fallbackAgent?.command ?? null;
}

View File

@@ -39,6 +39,27 @@ export interface ChatMessageAttachment {
filePath?: string; // original filesystem path (for ACP agents to read directly)
}
export interface UploadedFile {
id: string;
filename: string;
dataUrl: string;
base64Data: string;
mediaType: string;
filePath?: string;
}
export interface AIDraft {
text: string;
agentId: string;
attachments: UploadedFile[];
selectedUserSkillSlugs: string[];
updatedAt: number;
}
export type AIPanelView =
| { mode: 'draft' }
| { mode: 'session'; sessionId: string };
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';

View File

@@ -40,6 +40,33 @@ export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
export const STORAGE_KEY_AUTO_UPDATE_ENABLED = 'netcatty_auto_update_enabled_v1';
export const STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT = 'netcatty_local_vault_backup_max_count_v1';
export const STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION = 'netcatty_local_vault_backup_last_app_version_v1';
/**
* Cross-window barrier: set while a local vault restore is applying so
* auto-sync in another window doesn't upload a pre-restore snapshot
* concurrently. The value is an epoch-ms deadline — auto-sync treats any
* value in the future as "restore in progress" and any value in the past
* as a stale lock that can be ignored. See useAutoSync and
* CloudSyncSettings for readers/writers.
*/
export const STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL = 'netcatty_vault_restore_in_progress_until_v1';
/**
* Apply-in-progress sentinel. Set before a destructive applySyncPayload
* starts writing and cleared after it completes successfully. If this
* value is present on a later startup, the previous apply was
* interrupted mid-way (renderer crash, power loss, IPC failure) and the
* local vault is a partial mix of pre-apply and post-apply state.
* Auto-sync must refuse to push in that window — otherwise the partial
* state would silently overwrite an intact cloud copy — until the user
* manually restores from a protective backup or completes a full merge.
* The value is a JSON-encoded record (startedAt, protectiveBackupId,
* source) so the UI can surface a specific recovery hint rather than a
* generic "something broke" warning.
*/
export const STORAGE_KEY_VAULT_APPLY_IN_PROGRESS = 'netcatty_vault_apply_in_progress_v1';
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
@@ -112,6 +139,10 @@ export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
// Vault: Show Recently Connected hosts section
export const STORAGE_KEY_SHOW_RECENT_HOSTS = 'netcatty_show_recent_hosts_v1';
export const STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = 'netcatty_show_only_ungrouped_hosts_in_root_v1';
// Top tabs: Show standalone SFTP view tab
export const STORAGE_KEY_SHOW_SFTP_TAB = 'netcatty_show_sftp_tab_v1';
// Group Configurations (default settings inherited by hosts)
export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';

View File

@@ -45,8 +45,16 @@ import {
encryptProviderSecrets,
} from '../persistence/secureFieldAdapter';
import { mergeSyncPayloads } from '../../domain/syncMerge';
// Extracted into a plain ESM module so the signature logic is covered by
// the node --test harness (see syncSignature.test.mjs). The previous
// inline implementation only hashed a handful of meta fields and was
// trivially forgeable by a misbehaving adapter; v2 hashes the full meta
// plus a prefix of the ciphertext.
import { createSyncedFileSignature as createSyncedFileSignatureImpl } from './syncSignature.js';
import { decideRemoteChanged } from './syncAnchorDecision.js';
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
// ============================================================================
// Types
@@ -73,6 +81,15 @@ export interface SyncManagerState {
export type SyncEventCallback = (event: SyncEvent) => void;
interface ProviderSyncAnchor {
signature: string | null;
version: number;
updatedAt: number;
deviceId?: string;
resourceId?: string | null;
observedAt: number;
}
// ============================================================================
// CloudSyncManager Class
// ============================================================================
@@ -754,6 +771,7 @@ export class CloudSyncManager {
await this.saveProviderConnection('github', this.state.providers.github);
// Clear merge base when (re)authenticating to a potentially different account
this.removeFromStorage(this.syncBaseKey('github'));
this.clearSyncAnchor('github');
this.emit({
type: 'AUTH_COMPLETED',
provider: 'github',
@@ -809,6 +827,7 @@ export class CloudSyncManager {
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Clear merge base when (re)authenticating to a potentially different account
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -847,6 +866,7 @@ export class CloudSyncManager {
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Clear merge base when (re)configuring to a different endpoint/bucket
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -891,6 +911,7 @@ export class CloudSyncManager {
// Clear the merge base for this provider so reconnecting to a different
// account/resource doesn't reuse an unrelated snapshot
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
}
@@ -925,44 +946,187 @@ export class CloudSyncManager {
// Sync Operations
// ==========================================================================
/**
* Helper: Check for conflicts with a specific provider
*/
private async checkProviderConflict(
adapter: CloudAdapter
private syncAnchorKey(provider: CloudProvider): string {
return `${SYNC_REMOTE_ANCHOR_STORAGE_KEY}_${provider}`;
}
private createSyncedFileSignature(syncedFile: SyncedFile | null): Promise<string | null> {
return createSyncedFileSignatureImpl(syncedFile);
}
private loadSyncAnchor(provider: CloudProvider): ProviderSyncAnchor | null {
return this.loadFromStorage<ProviderSyncAnchor>(this.syncAnchorKey(provider));
}
private async saveSyncAnchor(
provider: CloudProvider,
syncedFile: SyncedFile | null,
resourceId?: string | null,
): Promise<void> {
this.saveToStorage(this.syncAnchorKey(provider), {
signature: await this.createSyncedFileSignature(syncedFile),
version: syncedFile?.meta.version ?? 0,
updatedAt: syncedFile?.meta.updatedAt ?? 0,
deviceId: syncedFile?.meta.deviceId,
resourceId: resourceId ?? this.state.providers[provider].resourceId ?? null,
observedAt: Date.now(),
} satisfies ProviderSyncAnchor);
}
private clearSyncAnchor(provider?: CloudProvider): void {
if (provider) {
this.removeFromStorage(this.syncAnchorKey(provider));
return;
}
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
this.removeFromStorage(this.syncAnchorKey(p));
}
}
private async inspectProviderRemoteState(
provider: CloudProvider,
adapter: CloudAdapter,
): Promise<{
conflict: boolean;
remoteChanged: boolean;
remoteFile: SyncedFile | null;
error?: string;
remoteFile?: SyncedFile;
}> {
try {
const remoteFile = await adapter.download();
const currentSignature = await this.createSyncedFileSignature(remoteFile);
const anchor = this.loadSyncAnchor(provider);
const currentResourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
if (remoteFile) {
// Compare versions
if (remoteFile.meta.updatedAt > this.state.localUpdatedAt) {
return {
conflict: true,
remoteFile,
};
}
}
return { conflict: false };
const decision = decideRemoteChanged({
currentSignature,
currentResourceId,
anchor,
hasRemoteFile: Boolean(remoteFile),
});
return {
remoteChanged: decision.remoteChanged,
remoteFile,
};
} catch (error) {
return { conflict: false, error: String(error) };
return {
remoteChanged: false,
remoteFile: null,
error: String(error),
};
}
}
/**
* Helper: Check for conflicts with a specific provider
*
* Fails closed on inspection error: throws rather than returning a
* `{conflict: false, error}` tuple. The previous return-shape let
* `syncAll`'s `validUploads` filter — which checks `!r.error` (the
* outer per-provider try/catch error) and `!r.check?.conflict` but
* NOT `r.check?.error` — admit this provider into the upload batch
* with `conflict: false`, which then proceeded to upload stale local
* data over the remote (the exact #711/#719 failure mode on a
* transient download 5xx). Throwing surfaces the failure through the
* same per-provider try/catch that already handles connection errors.
*/
private async checkProviderConflict(
provider: CloudProvider,
adapter: CloudAdapter
): Promise<{
conflict: boolean;
remoteFile?: SyncedFile;
}> {
const inspection = await this.inspectProviderRemoteState(provider, adapter);
if (inspection.error) {
throw new Error(inspection.error);
}
return {
conflict: inspection.remoteChanged && Boolean(inspection.remoteFile),
remoteFile: inspection.remoteFile ?? undefined,
};
}
async inspectProviderRemote(provider: CloudProvider): Promise<{
remoteChanged: boolean;
remoteFile: SyncedFile | null;
payload: SyncPayload | null;
}> {
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
throw new Error('Vault is locked');
}
const adapter = await this.getConnectedAdapter(provider);
const inspection = await this.inspectProviderRemoteState(provider, adapter);
if (inspection.error) {
throw new Error(inspection.error);
}
if (!inspection.remoteFile) {
return {
remoteChanged: inspection.remoteChanged,
remoteFile: null,
payload: null,
};
}
return {
remoteChanged: inspection.remoteChanged,
remoteFile: inspection.remoteFile,
payload: await EncryptionService.decryptPayload(inspection.remoteFile, this.masterPassword),
};
}
async commitRemoteInspection(
provider: CloudProvider,
remoteFile: SyncedFile,
payload: SyncPayload,
): Promise<void> {
const adapter = await this.getConnectedAdapter(provider);
const resourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
if (resourceId && this.state.providers[provider].resourceId !== resourceId) {
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
resourceId,
};
}
this.state.localVersion = remoteFile.meta.version;
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
this.state.remoteVersion = remoteFile.meta.version;
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = remoteFile.meta.version;
this.saveSyncConfig();
await this.saveSyncAnchor(provider, remoteFile, resourceId);
await this.saveSyncBase(payload, provider);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
}
/**
* Helper: Upload encrypted file to a provider
*
* `payloadForBase`, when supplied, is persisted as the new sync base
* BEFORE the anchor is advanced. Ordering matters: if the renderer
* crashes between the two writes, the next startup's inspect must
* either (a) see no anchor advance and re-merge against the fresh
* base, or (b) see both advanced consistently. The previous ordering
* (anchor before base) allowed a crash window where the next run
* saw "remote unchanged" (anchor matched) but silently kept a stale
* base, so a subsequent 3-way merge could misclassify entries that
* landed in this upload.
*/
private async uploadToProvider(
provider: CloudProvider,
adapter: CloudAdapter,
syncedFile: SyncedFile
syncedFile: SyncedFile,
payloadForBase?: SyncPayload,
): Promise<SyncResult> {
try {
await adapter.upload(syncedFile);
const resourceId = await adapter.upload(syncedFile);
this.state.lastError = null;
// Update local state (safe to do multiple times if values are same)
@@ -973,10 +1137,21 @@ export class CloudSyncManager {
// Invalidate any pending provider decrypt so it cannot overwrite
// the lastSync/lastSyncVersion we are about to set.
++this.providerDecryptSeq[provider];
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
this.state.providers[provider] = {
...this.state.providers[provider],
resourceId: resourceId || this.state.providers[provider].resourceId,
lastSync: Date.now(),
lastSyncVersion: syncedFile.meta.version,
};
this.saveSyncConfig();
// Persist base BEFORE anchor so a crash between them degrades
// safely: the stale anchor forces re-inspection next run, which
// merges against the fresh base and cannot silently drift.
if (payloadForBase) {
await this.saveSyncBase(payloadForBase, provider);
}
await this.saveSyncAnchor(provider, syncedFile, resourceId);
await this.saveProviderConnection(provider, this.state.providers[provider]);
this.notifyStateChange();
@@ -1090,12 +1265,11 @@ export class CloudSyncManager {
this.emit({ type: 'SYNC_STARTED', provider });
try {
// 1. Check for conflict
const checkResult = await this.checkProviderConflict(adapter);
if (checkResult.error) {
throw new Error(checkResult.error);
}
// 1. Check for conflict. `checkProviderConflict` throws on
// inspect failure, which the outer try/catch routes to the
// SYNC_ERROR path — so we never reach the upload branch with an
// unknown remote state.
const checkResult = await this.checkProviderConflict(provider, adapter);
if (checkResult.conflict && checkResult.remoteFile) {
// Remote is newer — attempt three-way merge instead of blocking
@@ -1112,7 +1286,7 @@ export class CloudSyncManager {
const base = await this.loadSyncBase(provider);
const mergeResult = mergeSyncPayloads(base, payload, remotePayload);
console.log('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
// Encrypt and upload merged payload
const mergedSyncedFile = await EncryptionService.encryptPayload(
@@ -1124,10 +1298,17 @@ export class CloudSyncManager {
checkResult.remoteFile.meta.version, // base on remote version
);
const uploadResult = await this.uploadToProvider(provider, adapter, mergedSyncedFile);
const uploadResult = await this.uploadToProvider(
provider,
adapter,
mergedSyncedFile,
mergeResult.payload,
);
if (uploadResult.success) {
await this.saveSyncBase(mergeResult.payload, provider);
// Base was persisted inside uploadToProvider before the
// anchor advanced, so a crash between them cannot leave a
// stale base pointing at pre-merge state.
this.state.syncState = 'IDLE';
this.addSyncHistoryEntry({
@@ -1190,11 +1371,12 @@ export class CloudSyncManager {
this.state.localVersion
);
// 3. Upload
const result = await this.uploadToProvider(provider, adapter, syncedFile);
// 3. Upload — base is persisted inside uploadToProvider before
// the anchor advances so a crash between them cannot leave the
// base pointing at a pre-upload snapshot.
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
if (result.success) {
await this.saveSyncBase(payload, provider);
this.state.syncState = 'IDLE';
} else {
this.state.syncState = 'ERROR';
@@ -1260,14 +1442,7 @@ export class CloudSyncManager {
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
}
// Update local tracking
this.state.localVersion = remoteFile.meta.version;
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
this.state.remoteVersion = remoteFile.meta.version;
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
this.saveSyncConfig();
await this.saveSyncBase(payload, provider);
this.notifyStateChange(); // Notify UI of state change
await this.commitRemoteInspection(provider, remoteFile, payload);
// Add to sync history
this.addSyncHistoryEntry({
@@ -1436,7 +1611,7 @@ export class CloudSyncManager {
this.updateProviderStatus(provider, 'syncing');
this.emit({ type: 'SYNC_STARTED', provider });
const check = await this.checkProviderConflict(adapter);
const check = await this.checkProviderConflict(provider, adapter);
return { provider, adapter, check };
} catch (error) {
return { provider, error: String(error) };
@@ -1446,8 +1621,50 @@ export class CloudSyncManager {
const checkResults = await Promise.all(checkTasks);
// 2. Analyze Results & Handle Conflicts — merge ALL conflicting providers
//
// Contract: every connected provider is assumed to mirror the *same*
// logical vault. When providers hold divergent content (e.g. user
// intentionally points GitHub and OneDrive at separate accounts with
// different data), uploading the conflict-merged payload below will
// overwrite provider-unique content on non-conflicting providers. A
// proper fix requires per-provider compare-and-swap (follow-up work,
// see I-1 and `docs/`). Until then, we log a diagnostic warning when
// we detect cross-provider base divergence so the issue is visible in
// support logs.
const conflicts = checkResults.filter((r) => !r.error && r.check?.conflict && r.check?.remoteFile);
// Instrumentation only — detect divergent provider bases (an
// unsupported configuration). Cheap: bases are already persisted
// and we only read their aggregate counts.
if (checkResults.filter((r) => !r.error).length > 1) {
try {
const summaries = await Promise.all(
checkResults
.filter((r) => !r.error)
.map(async (r) => {
const base = await this.loadSyncBase(r.provider as CloudProvider);
return {
provider: r.provider,
hosts: base?.hosts?.length ?? 0,
keys: base?.keys?.length ?? 0,
snippets: base?.snippets?.length ?? 0,
};
}),
);
const signatures = summaries.map((s) => `${s.hosts}/${s.keys}/${s.snippets}`);
const allSame = signatures.every((sig) => sig === signatures[0]);
if (!allSame) {
console.warn(
'[CloudSyncManager] syncAll: connected providers hold divergent bases (multi-account setup?). Uploading the conflict-merged payload will replace each provider\'s current remote. See I-7 in PR #720 for context.',
summaries,
);
}
} catch (diagError) {
// Non-fatal diagnostic; never let it block the sync.
console.warn('[CloudSyncManager] syncAll: base-divergence check failed:', diagError);
}
}
if (conflicts.length > 0) {
// Three-way merge: incorporate remote data from every conflicting provider
try {
@@ -1463,7 +1680,7 @@ export class CloudSyncManager {
}
const mergeResult = { payload: merged };
console.log('[CloudSyncManager] syncAll: three-way merge completed');
console.info('[CloudSyncManager] syncAll: three-way merge completed');
// Replace payload with merged payload for upload to all providers
payload = mergeResult.payload;
@@ -1587,9 +1804,13 @@ export class CloudSyncManager {
return results;
}
// 4. Parallel Uploads
// 4. Parallel Uploads — pass the payload so base is persisted
// inside uploadToProvider BEFORE the per-provider anchor advances.
// Ordering matters: a crash between the two writes must leave the
// stale anchor re-triggering inspection on next startup, not a
// fresh anchor paired with a stale base.
const uploadTasks = validUploads.map(async ({ provider, adapter }) => {
const result = await this.uploadToProvider(provider, adapter, syncedFile);
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
results.set(provider, result);
});
@@ -1599,12 +1820,6 @@ export class CloudSyncManager {
const hasSuccess = Array.from(results.values()).some((r) => r.success);
if (hasSuccess) {
this.state.syncState = 'IDLE';
// Save base per provider that successfully uploaded
if (payload) {
for (const [p, r] of results) {
if (r.success) await this.saveSyncBase(payload, p);
}
}
// If a merge happened, attach the merged payload to successful results
// so callers can apply remote additions to local state
@@ -1750,6 +1965,7 @@ export class CloudSyncManager {
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
this.removeFromStorage(this.syncBaseKey(p));
}
this.clearSyncAnchor();
}
private addSyncHistoryEntry(entry: Omit<SyncHistoryEntry, 'id'>): void {
@@ -1780,6 +1996,7 @@ export class CloudSyncManager {
this.saveSyncConfig();
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, []);
this.clearSyncBase();
this.clearSyncAnchor();
this.notifyStateChange();
}

View File

@@ -0,0 +1,73 @@
/**
* syncAnchorDecision — pure "has the remote changed since we last saw it?"
* logic extracted from CloudSyncManager so it can be exercised by
* `node --test` without standing up the full manager harness.
*
* Called from CloudSyncManager.inspectProviderRemoteState after the
* remote has been downloaded and its signature computed. Given the
* previous anchor and the current state, decides whether the remote
* looks different enough to warrant re-merging.
*
* Four decisions matter for data integrity:
*
* 1. Anchor missing + remote empty → not changed (first sync, nothing
* to merge from). Callers MUST still guard against pushing an empty
* local vault (see useAutoSync `hasMeaningfulSyncData`) — that guard
* is orthogonal to this decision.
* 2. Anchor missing + remote non-empty → changed (first sync, remote
* has data we've never observed → three-way merge with empty base).
* 3. Anchor present + resourceId drift → changed (provider created a
* fresh file; reuse of the old anchor would be meaningless).
* 4. Anchor present + signature mismatch → changed (same resource, new
* ciphertext — standard drift).
*
* Any other state is "unchanged", and callers short-circuit the merge.
*
* @param {{
* currentSignature: string | null,
* currentResourceId: string | null,
* anchor: { signature?: string | null, resourceId?: string | null } | null,
* hasRemoteFile: boolean,
* }} input
* @returns {{ remoteChanged: boolean, reason: string }}
*/
export function decideRemoteChanged(input) {
const { currentSignature, currentResourceId, anchor, hasRemoteFile } = input;
if (!anchor) {
// No anchor means we've never observed this provider.
if (!hasRemoteFile) {
// Remote has no file at all → nothing to merge.
return { remoteChanged: false, reason: 'no-anchor-no-remote' };
}
if (currentSignature === null) {
// hasRemoteFile=true but the signature computed to null — the
// file exists but we can't hash its meta (malformed shape, newer
// schema, partial download). Treat as CHANGED so the caller
// routes through the three-way merge / decrypt path rather than
// silently short-circuiting and letting the next upload overwrite
// an unreadable-but-extant remote file. If the payload is
// decryptable the merge will succeed; if it isn't, the decrypt
// error surfaces to the user, which is strictly safer than a
// silent stomp.
return { remoteChanged: true, reason: 'unreadable-remote' };
}
return { remoteChanged: true, reason: 'no-anchor-remote-has-data' };
}
// Resource identity drift: provider returned a different resource
// (e.g. a freshly-created gist, or the user reconnected and the
// adapter picked a new file). The previous anchor's signature is
// meaningless once the resource id changes.
const anchorResourceId = anchor.resourceId ?? null;
if (anchorResourceId !== currentResourceId) {
return { remoteChanged: true, reason: 'resource-id-changed' };
}
// Same resource, different signature → new ciphertext/meta.
if ((anchor.signature ?? null) !== currentSignature) {
return { remoteChanged: true, reason: 'signature-mismatch' };
}
return { remoteChanged: false, reason: 'anchor-matches' };
}

View File

@@ -0,0 +1,213 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decideRemoteChanged } from './syncAnchorDecision.js';
// -----------------------------------------------------------------------
// Anchor-missing branches
// -----------------------------------------------------------------------
test('no anchor + empty remote → not changed (first sync with empty cloud)', () => {
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: null,
anchor: null,
hasRemoteFile: false,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'no-anchor-no-remote');
});
test('no anchor + non-empty remote → changed (first sync with data in cloud)', () => {
// Critical: this is the "new device with existing cloud vault" path.
// Returning not-changed here would silently skip the three-way merge
// and let an empty local push clobber remote.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-remote',
currentResourceId: 'gist-1',
anchor: null,
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'no-anchor-remote-has-data');
});
test('no anchor + hasRemoteFile true but null signature → changed (unreadable remote, C3)', () => {
// Previously this returned `remoteChanged: false`, which silently
// routed callers down the "nothing to merge" short-circuit and then
// let the upload path stomp the malformed-but-extant remote file on
// the next push. Treating an unreadable remote as "changed" forces the
// three-way-merge branch — if the payload is decryptable the merge
// succeeds, and if it isn't the decrypt error surfaces to the user
// instead of being silently papered over by an overwrite.
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: 'gist-1',
anchor: null,
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'unreadable-remote');
});
// -----------------------------------------------------------------------
// Anchor-matches branches
// -----------------------------------------------------------------------
test('anchor matches signature and resourceId → not changed', () => {
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});
// -----------------------------------------------------------------------
// Anchor-stale branches
// -----------------------------------------------------------------------
test('anchor signature mismatch → changed', () => {
const result = decideRemoteChanged({
currentSignature: 'v3:sig-NEW',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-OLD', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
test('anchor resourceId mismatch → changed (even when signatures happen to match)', () => {
// Provider created a fresh file (gist recreated, Drive file recreated).
// The old anchor's signature is meaningless once the resource id drifts.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-SAME',
currentResourceId: 'gist-NEW',
anchor: { signature: 'v3:sig-SAME', resourceId: 'gist-OLD' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
test('anchor resourceId was null, now has value → changed', () => {
// Before: user connected but first-sync had no resource yet.
// Now: provider returned a concrete id. Treat as changed so the
// follow-up re-inspects correctly.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: 'gist-1',
anchor: { signature: 'v3:sig-A', resourceId: null },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
test('anchor resourceId had value, now null → changed', () => {
// Adapter lost the resource id somehow (disconnect, re-login). The
// old signature-based comparison is not trustworthy here.
const result = decideRemoteChanged({
currentSignature: 'v3:sig-A',
currentResourceId: null,
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'resource-id-changed');
});
// -----------------------------------------------------------------------
// Defensive shapes
// -----------------------------------------------------------------------
test('anchor with undefined signature → changed unless current is also null', () => {
// `anchor.signature` missing (pre-v2 persisted record, say) and
// `currentSignature` non-null → must not treat as match.
const changed = decideRemoteChanged({
currentSignature: 'v3:sig',
currentResourceId: 'id-1',
anchor: { resourceId: 'id-1' },
hasRemoteFile: true,
});
assert.equal(changed.remoteChanged, true);
assert.equal(changed.reason, 'signature-mismatch');
});
test('anchor signature null and current signature null with same resourceId → not changed', () => {
// The legitimate "empty-on-both-sides already observed" case.
const result = decideRemoteChanged({
currentSignature: null,
currentResourceId: 'id-1',
anchor: { signature: null, resourceId: 'id-1' },
hasRemoteFile: false,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});
// -----------------------------------------------------------------------
// Migration: stored v2 anchor → fresh v3 signature from this build
// -----------------------------------------------------------------------
test('v2 anchor persisted from older build → signature-mismatch against v3 (migration)', () => {
// A user upgrading from a build that persisted `v2:<prefix-hash>` must
// see the next startup inspection treat the remote as "changed". The
// v3 signature format is `v3:{...meta}|len=...|sha256=...`; the two
// strings can never compare equal, so the decision routes through
// three-way merge and re-observes the remote. Without this property
// a stale v2 anchor would be treated as authoritative, skipping the
// merge and letting local-only state overwrite remote — the very
// #711/#719 failure path.
const result = decideRemoteChanged({
currentSignature: 'v3:{"appVersion":"1.0.0"}|len=80|sha256=' + 'a'.repeat(64),
currentResourceId: 'gist-1',
anchor: { signature: 'v2:abcdef1234567890', resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
// -----------------------------------------------------------------------
// Regression: issues #711 / #719 — stale-device-overwrites-newer-remote
// -----------------------------------------------------------------------
test('stale device sees fresh remote → triggers merge, not overwrite (#711/#719)', () => {
// Scenario: Device A syncs at T0, anchor records signature sigA.
// User edits on Device B at T1 → remote signature becomes sigB.
// Device A then wakes up with a stale anchor (sigA) and the fresh
// remote (sigB). The decision MUST say "remote changed" so the
// sync path three-way merges Device A's local into remote instead
// of short-circuiting to "no change" and overwriting Device B's edit.
const sigA = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
const sigB = 'v3:{"updatedAt":1700000300000}|len=80|sha256=' + 'b'.repeat(64);
const result = decideRemoteChanged({
currentSignature: sigB,
currentResourceId: 'gist-1',
anchor: { signature: sigA, resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, true);
assert.equal(result.reason, 'signature-mismatch');
});
test('fresh device, same-signature anchor → no spurious merge (#711/#719 inverse)', () => {
// Inverse guard: a device whose anchor matches the current remote
// signature must NOT be dragged through a merge round-trip, which
// would cause the "everyone re-uploads on every startup" thrash seen
// in the pre-anchor implementation. This locks in that the anchor
// logic correctly short-circuits the common case.
const sig = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
const result = decideRemoteChanged({
currentSignature: sig,
currentResourceId: 'gist-1',
anchor: { signature: sig, resourceId: 'gist-1' },
hasRemoteFile: true,
});
assert.equal(result.remoteChanged, false);
assert.equal(result.reason, 'anchor-matches');
});

View File

@@ -0,0 +1,130 @@
/**
* syncSignature - Provider-agnostic remote snapshot fingerprint.
*
* Stable, order-independent signature of a SyncedFile used by
* CloudSyncManager to decide whether a remote has changed since we last
* observed it. Must produce the same value for semantically-identical
* remotes and a different value for any ciphertext/metadata change.
*
* Kept as a plain ESM .js file (JSDoc-typed) so it works seamlessly with
* both Vite's bundler in the renderer AND Node's `node --test` harness
* without needing a TypeScript test runner. CloudSyncManager.ts imports
* it via a normal ESM import.
*
* The previous implementation in CloudSyncManager only hashed
* `[version, updatedAt, deviceId, iv, salt]`. That meant:
* - a misbehaving adapter could replay those five fields while
* mutating algorithm/kdf/appVersion and the anchor would treat the
* remote as unchanged;
* - deviceId (a field the remote controls) was weighted as strongly
* as iv/salt;
* - ciphertext changes with metadata held constant could slip past.
*
* v3 hashes the full meta object (sorted for stability) plus the
* SHA-256 of the full payload ciphertext so any of those mutations flip
* the anchor. v2 used only a 64-char prefix of the ciphertext, which is
* easily defeated by an adversary that controls the remote and can
* tail-mutate while preserving the prefix. v3 is resistant to any
* ciphertext mutation.
*
* Version prefixes are part of the signature string itself (`v3:`) so
* an older anchor persisted from a previous build will simply never
* compare equal to a fresh signature from this build, forcing a
* single-cycle safe re-detection (treated as "remote changed" which
* triggers three-way merge) rather than a silent mismatch.
*
* INVARIANT: `meta` values must be primitives (strings, numbers,
* booleans, null/undefined). Nested objects or arrays in meta would
* serialize via JSON.stringify, which does NOT sort keys — breaking
* signature stability. All current SyncedFile meta fields satisfy this.
*/
/**
* Sentinel error for a missing WebCrypto subtle digest — see
* `sha256Hex` and `createSyncedFileSignature` for the fail-closed
* handling.
*/
class SyncSignatureUnavailableError extends Error {
constructor() {
super('WebCrypto subtle.digest is unavailable; signature cannot be computed safely.');
this.name = 'SyncSignatureUnavailableError';
}
}
/**
* Compute SHA-256 of a UTF-8 string, returning lowercase hex.
*
* Uses `globalThis.crypto.subtle` (Web Crypto API) which is available in
* both the Electron renderer and Node.js ≥ 19 (the repo's runtime targets
* both, and CI/tests run under Node). Keeping to the Web Crypto API also
* avoids pulling `node:crypto` into the renderer bundle.
*
* Throws `SyncSignatureUnavailableError` when subtle.digest is missing.
* Earlier revisions returned a length-only fallback string (`nosha-N`),
* which would produce a short, truncation-trivial pseudo-signature that
* an attacker controlling the remote could alias against a legitimate
* v3 signature of the same length. Failing loudly here lets the caller
* in `createSyncedFileSignature` return `null`, which routes through
* the "unreadable remote → treat as changed → three-way merge or
* surface decrypt error" path — strictly safer than a weak signature.
*
* @param {string} input
* @returns {Promise<string>}
*/
async function sha256Hex(input) {
const subtle = globalThis.crypto?.subtle;
if (!subtle?.digest) {
throw new SyncSignatureUnavailableError();
}
const bytes = new globalThis.TextEncoder().encode(input);
const buf = await subtle.digest('SHA-256', bytes);
const arr = new Uint8Array(buf);
let hex = '';
for (let i = 0; i < arr.length; i += 1) {
hex += arr[i].toString(16).padStart(2, '0');
}
return hex;
}
/**
* @param {import('../../domain/sync').SyncedFile | null} syncedFile
* @returns {Promise<string | null>}
*/
export async function createSyncedFileSignature(syncedFile) {
if (!syncedFile) return null;
const { meta, payload } = syncedFile;
if (!meta || typeof meta !== 'object') return null;
// Serialize meta as a canonical JSON object with keys sorted. Earlier
// versions joined `${key}=${JSON.stringify(...)}` with `|`, which left
// the `=` separator unescaped: a future meta key containing `=` in its
// name (or a string value that mimics the separator syntax) could
// alias with a different key/value pair. JSON.stringify of a sorted
// plain object is injection-proof because string values are quoted
// and escaped by the serializer.
const metaKeys = Object.keys(meta).sort();
const canonicalMeta = {};
for (const key of metaKeys) {
canonicalMeta[key] = meta[key] ?? null;
}
const metaSerialized = JSON.stringify(canonicalMeta);
const payloadStr = typeof payload === 'string' ? payload : '';
const payloadLen = payloadStr.length;
let payloadHash;
try {
payloadHash = payloadStr ? await sha256Hex(payloadStr) : 'empty';
} catch (error) {
if (error instanceof SyncSignatureUnavailableError) {
// Fail closed: no signature → decideRemoteChanged's
// `currentSignature === null` branch treats the remote as
// "unreadable" and routes through three-way merge. That is the
// safe behavior vs. a weak pseudo-signature that could silently
// alias against another payload of the same length.
return null;
}
throw error;
}
return `v3:${metaSerialized}|len=${payloadLen}|sha256=${payloadHash}`;
}

View File

@@ -0,0 +1,212 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSyncedFileSignature } from './syncSignature.js';
function makeSyncedFile(overrides = {}) {
const meta = {
version: 1,
updatedAt: 1_700_000_000_000,
deviceId: 'device-a',
deviceName: 'Device A',
appVersion: '1.0.0',
iv: 'BASE64_IV',
salt: 'BASE64_SALT',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
kdfIterations: 600000,
...(overrides.meta || {}),
};
return {
meta,
payload: overrides.payload ?? 'CIPHERTEXTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
};
}
test('null file produces null signature', async () => {
assert.equal(await createSyncedFileSignature(null), null);
});
test('two identical files produce identical signatures', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile();
assert.equal(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('signature is stable across meta key-insertion order', async () => {
const canonical = makeSyncedFile();
const shuffled = {
meta: {
kdf: 'PBKDF2',
salt: 'BASE64_SALT',
iv: 'BASE64_IV',
appVersion: '1.0.0',
deviceName: 'Device A',
deviceId: 'device-a',
updatedAt: 1_700_000_000_000,
version: 1,
algorithm: 'AES-256-GCM',
kdfIterations: 600000,
},
payload: canonical.payload,
};
assert.equal(await createSyncedFileSignature(canonical), await createSyncedFileSignature(shuffled));
});
test('changing iv flips the signature', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile({ meta: { iv: 'DIFFERENT_IV' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing salt flips the signature', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile({ meta: { salt: 'DIFFERENT_SALT' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing updatedAt flips the signature', async () => {
const a = makeSyncedFile();
const b = makeSyncedFile({ meta: { updatedAt: 1_700_000_000_001 } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing algorithm flips the signature (v1 regression guard)', async () => {
// The old signature only hashed version/updatedAt/deviceId/iv/salt — an
// adapter could have changed algorithm/kdf while holding those constant.
// v2+ must reject that.
const a = makeSyncedFile({ meta: { algorithm: 'AES-256-GCM' } });
const b = makeSyncedFile({ meta: { algorithm: 'ChaCha20-Poly1305' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing kdf flips the signature (v1 regression guard)', async () => {
const a = makeSyncedFile({ meta: { kdf: 'PBKDF2' } });
const b = makeSyncedFile({ meta: { kdf: 'Argon2id' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing appVersion flips the signature (v1 regression guard)', async () => {
const a = makeSyncedFile({ meta: { appVersion: '1.0.0' } });
const b = makeSyncedFile({ meta: { appVersion: '2.0.0' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing payload ciphertext flips the signature even when meta matches', async () => {
// Critical: a malicious or buggy adapter could replay meta while swapping
// the ciphertext. v2+ must treat the payload as load-bearing.
const a = makeSyncedFile({ payload: 'AAA' + 'x'.repeat(60) });
const b = makeSyncedFile({ payload: 'BBB' + 'x'.repeat(60) });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('changing payload length flips the signature (truncation guard)', async () => {
// v3 hashes the full ciphertext — any length difference flips the signature.
const prefix = 'x'.repeat(64);
const a = makeSyncedFile({ payload: prefix });
const b = makeSyncedFile({ payload: `${prefix}extra` });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('tail-mutation of a long ciphertext flips the signature (v2 prefix-replay guard)', async () => {
// v2 only hashed the first 64 chars of the ciphertext. An adversary with
// write access to the remote could preserve the prefix and mutate only the
// tail, producing a signature collision. v3 hashes the full ciphertext and
// must catch tail mutations even when prefix + length are preserved.
const prefix = 'x'.repeat(64);
const tailA = 'AAAAAAAAAAAAAAAA';
const tailB = 'BBBBBBBBBBBBBBBB';
const a = makeSyncedFile({ payload: `${prefix}${tailA}` });
const b = makeSyncedFile({ payload: `${prefix}${tailB}` });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('deviceId alone is not sufficient to match (metadata weighted properly)', async () => {
// Both share deviceId but differ on iv — must not alias.
const a = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_A' } });
const b = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_B' } });
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
});
test('missing optional meta fields hash as null rather than throwing', async () => {
const partial = {
meta: {
version: 1,
updatedAt: 1_700_000_000_000,
deviceId: 'device',
appVersion: '1.0.0',
iv: 'IV',
salt: 'S',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
// deviceName and kdfIterations omitted intentionally
},
payload: 'short',
};
const sig = await createSyncedFileSignature(partial);
assert.equal(typeof sig, 'string');
assert.ok(sig.startsWith('v3:'));
});
test('file with non-string payload produces signature with len=0', async () => {
// Defensive: if an adapter somehow yields a non-string payload, we still
// generate a well-formed signature rather than crashing.
const weird = { meta: makeSyncedFile().meta, payload: null };
const sig = await createSyncedFileSignature(weird);
assert.ok(sig);
assert.ok(sig.includes('len=0'));
assert.ok(sig.includes('sha256='));
});
test('signature contains a 64-char hex SHA-256 segment', async () => {
// Lock in the hash algorithm choice so a future regression to prefix-hashing
// is caught by this unit test.
const file = makeSyncedFile();
const sig = await createSyncedFileSignature(file);
assert.ok(sig);
const match = sig.match(/sha256=([a-f0-9]+)/);
assert.ok(match, `expected sha256=<hex> in signature, got ${sig}`);
assert.equal(match[1].length, 64);
});
test('v2-format anchor string does not equal a v3 signature', async () => {
// Migration guard: if a user's localStorage carries a v2-prefixed anchor
// from a previous build, comparing against a fresh v3 signature must flip
// to "remote changed" so we re-observe rather than treating a stale anchor
// as authoritative.
const file = makeSyncedFile();
const v3 = await createSyncedFileSignature(file);
const v2Like = String(v3).replace(/^v3:/, 'v2:').replace(/sha256=[a-f0-9]+$/, 'head=xxxxxxxxxxxxxxxx');
assert.notEqual(v3, v2Like);
});
test('missing WebCrypto subtle → signature is null (fail-closed, no weak fallback)', async () => {
// Earlier revisions returned `nosha-<length>` when subtle.digest was
// unavailable. That fallback was length-only, so an adversary
// controlling the remote could trivially produce a payload whose
// weak pseudo-signature equals a legitimate v3 signature of the
// same length. Failing to `null` routes decideRemoteChanged into the
// "unreadable remote → treat as changed → three-way merge" path,
// which is strictly safer.
//
// `globalThis.crypto` is a read-only getter in Node, so we override
// the `subtle` property on the existing object rather than
// reassigning the whole binding.
const subtleDescriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'subtle');
Object.defineProperty(globalThis.crypto, 'subtle', {
configurable: true,
get() {
return undefined;
},
});
try {
const sig = await createSyncedFileSignature(makeSyncedFile());
assert.equal(sig, null, 'missing subtle must not produce a weak fallback string');
} finally {
if (subtleDescriptor) {
Object.defineProperty(globalThis.crypto, 'subtle', subtleDescriptor);
} else {
delete globalThis.crypto.subtle;
}
}
});

38
package-lock.json generated
View File

@@ -1154,7 +1154,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1800,6 +1799,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1821,6 +1821,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1837,6 +1838,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1851,6 +1853,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3306,7 +3309,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -6295,7 +6297,6 @@
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "*"
}
@@ -6376,7 +6377,6 @@
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0",
@@ -6406,7 +6406,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -6936,7 +6935,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6987,7 +6985,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7548,7 +7545,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8291,7 +8287,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8575,7 +8572,6 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -8957,6 +8953,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8977,6 +8974,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9206,7 +9204,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10555,7 +10552,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12045,7 +12041,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -12663,8 +12658,7 @@
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
@@ -12919,6 +12913,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -12931,7 +12926,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -13691,6 +13685,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13708,6 +13703,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13898,7 +13894,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13908,7 +13903,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -15229,6 +15223,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15293,6 +15288,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -15367,7 +15363,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15560,7 +15555,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15581,7 +15575,6 @@
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "^3.0.0",
"bail": "^2.0.0",
@@ -15920,7 +15913,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16014,7 +16006,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16293,7 +16284,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}